From 7aec62b55624c1471200db35a223bf38469208c5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 30 Jul 2026 17:47:07 +0000
Subject: [PATCH 01/37] Initial plan
From 9e0feef3a4270f136d3894f15d32049ec9cc2986 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 30 Jul 2026 18:00:47 +0000
Subject: [PATCH 02/37] Add calibration service and WPF workflow
---
.../Services/ServiceCalibrationTests.cs | 95 ++++++++
.../ServiceOperationProgressHandler.cs | 140 ++++++++++++
.../Services/ServiceOperationRequest.cs | 14 +-
TapeLibNET/Services/ServiceOperationResult.cs | 37 ++++
TapeLibNET/Services/TapeServiceBase.EW.cs | 202 ++++++++++++++++++
TapeWinNET/CalibrateWindow.xaml | 120 +++++++++++
TapeWinNET/CalibrateWindow.xaml.cs | 52 +++++
TapeWinNET/CalibrationWindow.xaml | 125 +++++++++++
TapeWinNET/CalibrationWindow.xaml.cs | 52 +++++
.../Controls/CalibrationCurveControl.xaml | 45 ++++
.../Controls/CalibrationCurveControl.xaml.cs | 183 ++++++++++++++++
TapeWinNET/MainWindow.xaml | 2 +
.../Services/TapeService.Calibration.cs | 33 +++
TapeWinNET/Services/WpfServiceHost.cs | 16 ++
TapeWinNET/ViewModels/CalibrationViewModel.cs | 201 +++++++++++++++++
.../ViewModels/MainViewModel.Calibration.cs | 202 ++++++++++++++++++
TapeWinNET/ViewModels/MainViewModel.cs | 50 +++--
17 files changed, 1551 insertions(+), 18 deletions(-)
create mode 100644 TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
create mode 100644 TapeLibNET/Services/TapeServiceBase.EW.cs
create mode 100644 TapeWinNET/CalibrateWindow.xaml
create mode 100644 TapeWinNET/CalibrateWindow.xaml.cs
create mode 100644 TapeWinNET/CalibrationWindow.xaml
create mode 100644 TapeWinNET/CalibrationWindow.xaml.cs
create mode 100644 TapeWinNET/Controls/CalibrationCurveControl.xaml
create mode 100644 TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
create mode 100644 TapeWinNET/Services/TapeService.Calibration.cs
create mode 100644 TapeWinNET/ViewModels/CalibrationViewModel.cs
create mode 100644 TapeWinNET/ViewModels/MainViewModel.Calibration.cs
diff --git a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
new file mode 100644
index 0000000..ee2029e
--- /dev/null
+++ b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
@@ -0,0 +1,95 @@
+using TapeLibNET.Services;
+using TapeLibNET.Tests.Helpers;
+using TapeLibNET.Virtual;
+
+namespace TapeLibNET.Tests.Services;
+
+public class ServiceCalibrationTests : ServiceTestBase
+{
+ private const long MB = 1024L * 1024;
+ private const long CalibrationCapacity = 64L * MB;
+
+ private static async Task<(TapeServiceBase service, TestTapeServiceHost host)> OpenCalibrationServiceAsync(
+ long capacity = CalibrationCapacity,
+ VirtualTapeDriveIoRate? ioRate = null)
+ {
+ var (service, host) = CreateService();
+
+ var vmd = new VirtualMediaDescriptor("memory-calibration", capacity, null, 0, InMemory: true);
+
+ Assert.True(await service.OpenVirtualDriveAsync(
+ VirtualTapeDriveCapabilities.WithFilemarksOnlyLargeBlocks,
+ vmd,
+ ioRate: ioRate,
+ ewProfile: VirtualTapeEwProfile.Lto4Like(capacity)),
+ $"OpenVirtualDriveAsync failed: {service.LastError}");
+
+ Assert.True(await service.LoadMediaAsync(),
+ $"LoadMediaAsync failed: {service.LastError}");
+
+ return (service, host);
+ }
+
+ [Fact]
+ public async Task ExecuteCalibrateAsync_ReturnsCalibrationAndLogsSummary()
+ {
+ var (service, host) = await OpenCalibrationServiceAsync();
+ using (service)
+ {
+ var result = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(
+ EjectWhenDone: false,
+ Options: new TapeCalibrationOptions
+ {
+ SampleCount = 20,
+ MinSampleInterval = 1L * MB,
+ ChunkBytesTarget = 1L * MB,
+ }));
+
+ Assert.True(result.Success);
+ Assert.False(result.WasAborted);
+ Assert.NotNull(result.Calibration);
+ Assert.Equal(service.DriveProfileKey, result.ProfileKey);
+ Assert.True(result.CapacityActual > 0);
+ Assert.True(result.EwToEomDistance > 0);
+ Assert.Contains(ServiceStateChange.OperationStarted, host.StateChanges);
+ Assert.Contains(ServiceStateChange.OperationEnded, host.StateChanges);
+ Assert.True(host.ContainsMessage("Calibration summary"));
+ Assert.True(host.ContainsMessage("Calibration completed successfully"));
+ }
+ }
+
+ [Fact]
+ public async Task ExecuteCalibrateAsync_HonorsAbortRequest()
+ {
+ var (service, host) = await OpenCalibrationServiceAsync(
+ capacity: 256L * MB,
+ ioRate: new VirtualTapeDriveIoRate { BytesPerSecond = 8L * MB });
+
+ using (service)
+ using (var cts = new CancellationTokenSource())
+ {
+ var task = service.ExecuteCalibrateAsync(
+ new CalibrateRequest(
+ EjectWhenDone: false,
+ Options: new TapeCalibrationOptions
+ {
+ SampleCount = 16,
+ MinSampleInterval = 1L * MB,
+ ChunkBytesTarget = 1L * MB,
+ })
+ {
+ Cancellation = cts.Token,
+ });
+
+ await Task.Delay(100);
+ cts.Cancel();
+
+ var result = await task;
+ Assert.True(result.WasAborted);
+ Assert.False(result.Success);
+ Assert.Null(result.Calibration);
+ Assert.True(host.ContainsMessage("Calibration abort requested"));
+ }
+ }
+}
diff --git a/TapeLibNET/Services/ServiceOperationProgressHandler.cs b/TapeLibNET/Services/ServiceOperationProgressHandler.cs
index 3c4b414..db9b11e 100644
--- a/TapeLibNET/Services/ServiceOperationProgressHandler.cs
+++ b/TapeLibNET/Services/ServiceOperationProgressHandler.cs
@@ -318,3 +318,143 @@ private void AddToProcessed(in TapeFileInfo fileInfo, int setIndex)
list.Add(fileInfo);
}
}
+
+// ── Calibrate ────────────────────────────────────────────────────────────────
+
+///
+/// Progress adapter for destructive calibration runs. Mirrors the service-operation pattern used
+/// by backup and restore, but maps calibration chunks → pseudo-files so existing overlays can
+/// reuse their file-progress shape.
+///
+public class ServiceCalibrateProgressHandler(
+ ITapeServiceHost host,
+ TapeCalibrator calibrator,
+ long capacityReported)
+ : IProgress
+{
+ private readonly ITapeServiceHost _host = host;
+
+ /// The live calibrator driving the operation.
+ protected readonly TapeCalibrator Calibrator = calibrator;
+
+ private readonly int _estimatedChunkBytes = checked((int)Math.Max(1L, calibrator.Options.ChunkBytesTarget));
+ private bool _abortLogged;
+ private bool _ewLogged;
+
+ /// Estimated number of chunks needed to traverse the medium.
+ public int FilesTotal { get; private set; } =
+ capacityReported > 0
+ ? (int)Math.Min(int.MaxValue, (capacityReported + Math.Max(1L, calibrator.Options.ChunkBytesTarget) - 1L)
+ / Math.Max(1L, calibrator.Options.ChunkBytesTarget))
+ : 0;
+
+ /// Estimated media capacity reported by the drive at BOT.
+ public long BytesTotal { get; private set; } = Math.Max(0L, capacityReported);
+
+ /// Chunks written so far (pseudo-file count).
+ public int FilesProcessed { get; private set; }
+
+ /// Chunks successfully written so far (pseudo-file count).
+ public int FilesSucceeded { get; private set; }
+
+ /// No per-chunk failures are surfaced separately for calibration.
+ public int FilesFailed { get; private set; }
+
+ /// No per-chunk skips are surfaced separately for calibration.
+ public int FilesSkipped { get; private set; }
+
+ /// Bytes written so far.
+ public long BytesProcessed { get; private set; }
+
+ /// Current calibration phase, humanised for UI display.
+ public string CurrentPhase { get; private set; } = "Preparing calibration";
+
+ /// Finalises any host-specific progress display. No-op in the base implementation.
+ public virtual void CompleteProgress() { }
+
+ /// Releases any host-specific progress resources. No-op in the base implementation.
+ public virtual void DisposeProgress() { }
+
+ /// Hook for app-specific progress UI updates.
+ protected virtual void ReportProgress(TapeCalibrationProgress progress) { }
+
+ ///
+ /// Throws when the calibrator has been asked to abort,
+ /// logging that state transition exactly once.
+ ///
+ protected void ThrowIfAbortRequested()
+ {
+ if (!Calibrator.IsAbortRequested) return;
+ if (!_abortLogged)
+ {
+ _abortLogged = true;
+ _host.Report(ServiceReportLevel.Warning, "Calibration abort requested");
+ }
+ throw new TapeAbortRequestedException("User requested abort");
+ }
+
+ ///
+ public void Report(TapeCalibrationProgress progress)
+ {
+ ThrowIfAbortRequested();
+
+ BytesProcessed = Math.Max(0L, progress.BytesWritten);
+ FilesProcessed = _estimatedChunkBytes > 0
+ ? (int)Math.Min(int.MaxValue, (BytesProcessed + _estimatedChunkBytes - 1L) / _estimatedChunkBytes)
+ : 0;
+ FilesSucceeded = FilesProcessed;
+ CurrentPhase = FormatPhase(progress.Phase);
+
+ if (progress.EarlyWarning && !_ewLogged)
+ {
+ _ewLogged = true;
+ _host.Report(ServiceReportLevel.Info, "Calibration captured the physical early-warning landmark");
+ }
+
+ ReportProgress(progress);
+ }
+
+ ///
+ /// Builds a from the accumulated progress state and an optional
+ /// completed calibration artifact.
+ ///
+ public CalibrateResult GenerateResult(
+ ITapeCalibration? calibration,
+ bool aborted = false,
+ bool failed = false,
+ TimeSpan duration = default,
+ string? message = null,
+ Exception? error = null) => new()
+ {
+ FilesTotal = FilesTotal,
+ BytesTotal = BytesTotal,
+ FilesProcessed = FilesProcessed,
+ FilesSucceeded = FilesSucceeded,
+ FilesFailed = FilesFailed,
+ FilesSkipped = FilesSkipped,
+ BytesProcessed = calibration?.CapacityActual ?? BytesProcessed,
+ WasAborted = aborted,
+ HasFailed = failed,
+ Success = !aborted && !failed && calibration is not null,
+ Outcome = aborted ? ServiceReportLevel.Failed
+ : failed ? ServiceReportLevel.Error
+ : ServiceReportLevel.Completed,
+ Duration = duration,
+ Message = message,
+ Error = error,
+ Calibration = calibration,
+ ProfileKey = calibration?.ProfileKey ?? string.Empty,
+ CapacityReported = calibration?.CapacityReported ?? BytesTotal,
+ CapacityActual = calibration?.CapacityActual ?? BytesProcessed,
+ EarlyWarning = calibration?.EarlyWarning,
+ EwToEomDistance = calibration?.EwToEomDistance ?? 0L,
+ };
+
+ private static string FormatPhase(string phase) => phase switch
+ {
+ "sampling" => "Writing to EOM",
+ "early-warning" => "Capturing EW landmark",
+ "eom" => "Finalizing calibration",
+ _ => string.IsNullOrWhiteSpace(phase) ? "Calibrating" : phase,
+ };
+}
diff --git a/TapeLibNET/Services/ServiceOperationRequest.cs b/TapeLibNET/Services/ServiceOperationRequest.cs
index f368f6d..1c7a7d1 100644
--- a/TapeLibNET/Services/ServiceOperationRequest.cs
+++ b/TapeLibNET/Services/ServiceOperationRequest.cs
@@ -68,6 +68,19 @@ public sealed record RestoreRequest(
bool EjectWhenDone,
ITapeFileFilter? Filter = null) : ServiceOperationRequest;
+// ── Calibrate ────────────────────────────────────────────────────────────────
+
+///
+/// Options for a destructive calibration run over the currently loaded medium.
+///
+///
+/// Calibration works on fixed-size write chunks rather than user files, but it still
+/// follows the same service-operation pattern as backup and restore.
+///
+public sealed record CalibrateRequest(
+ bool EjectWhenDone,
+ TapeCalibrationOptions Options) : ServiceOperationRequest;
+
// ── List ─────────────────────────────────────────────────────────────────────
///
@@ -131,4 +144,3 @@ public sealed record ListRequest(
ITapeFileFilter? Filter = null,
ListDepth Depth = ListDepth.Full) : ServiceOperationRequest;
-
diff --git a/TapeLibNET/Services/ServiceOperationResult.cs b/TapeLibNET/Services/ServiceOperationResult.cs
index 57a1ff2..8238554 100644
--- a/TapeLibNET/Services/ServiceOperationResult.cs
+++ b/TapeLibNET/Services/ServiceOperationResult.cs
@@ -110,6 +110,43 @@ public sealed record RestoreResult : FileOperationResult
public Dictionary> ProcessedFiles { get; init; } = [];
}
+// ── Calibrate ────────────────────────────────────────────────────────────────
+
+///
+/// Summary statistics returned by a calibration operation.
+///
+///
+/// To preserve the established operation-triad shape, the inherited "file" counters map
+/// calibration chunks → files. / remain
+/// the more meaningful quantities for callers and UI progress.
+///
+public sealed record CalibrateResult : FileOperationResult
+{
+ /// The calibration produced by the run, or on failure/abort.
+ public ITapeCalibration? Calibration { get; init; }
+
+ /// Matched drive+media profile key for this run.
+ public string ProfileKey { get; init; } = string.Empty;
+
+ /// Driver-reported capacity at BOT (bytes).
+ public long CapacityReported { get; init; }
+
+ /// True raw capacity measured at hard EOM (bytes).
+ public long CapacityActual { get; init; }
+
+ /// Captured EW landmark, or when none was observed.
+ public CalibrationPoint? EarlyWarning { get; init; }
+
+ /// Bytes still writable when EW fired, or 0 when no EW landmark was observed.
+ public long EwToEomDistance { get; init; }
+
+ /// Number of points in the calibrated curve.
+ public int CurvePointCount => Calibration?.Curve.Count ?? 0;
+
+ ///
+ public override bool IsFullSuccess => base.IsFullSuccess && Calibration is not null;
+}
+
// ── List ─────────────────────────────────────────────────────────────────────
///
diff --git a/TapeLibNET/Services/TapeServiceBase.EW.cs b/TapeLibNET/Services/TapeServiceBase.EW.cs
new file mode 100644
index 0000000..d70d7ce
--- /dev/null
+++ b/TapeLibNET/Services/TapeServiceBase.EW.cs
@@ -0,0 +1,202 @@
+using Windows.Win32.Foundation;
+using Windows.Win32.System.SystemServices; // Helpers, Stopwatch
+
+using Stopwatch = Windows.Win32.System.SystemServices.Stopwatch;
+
+namespace TapeLibNET.Services;
+
+public partial class TapeServiceBase
+{
+ // ── Calibration ───────────────────────────────────────────────────────────
+
+ ///
+ /// Executes a destructive calibration run against the currently loaded medium.
+ ///
+ public Task ExecuteCalibrateAsync(CalibrateRequest request)
+ {
+ _host.OnServiceStateChanged(ServiceStateChange.OperationStarted);
+
+ return Task.Run(async () =>
+ {
+ await _operationLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ var result = ExecuteCalibrateCore(request);
+
+ if (request.EjectWhenDone)
+ {
+ LogInfo("Ejecting media after calibration...");
+ EjectMediaCore();
+ }
+
+ return result;
+ }
+ finally
+ {
+ _operationLock.Release();
+ _host.OnServiceStateChanged(ServiceStateChange.OperationEnded);
+ }
+ });
+ }
+
+ private CalibrateResult ExecuteCalibrateCore(CalibrateRequest request)
+ {
+ ServiceCalibrateProgressHandler? progressHandler = null;
+ TapeCalibrator? calibrator = null;
+ var timer = new Stopwatch();
+
+ CalibrateResult MakeResult(
+ ITapeCalibration? calibration = null,
+ bool aborted = false,
+ bool failed = false,
+ string? message = null,
+ Exception? error = null)
+ => progressHandler?.GenerateResult(
+ calibration,
+ aborted: aborted,
+ failed: failed,
+ duration: timer.ElapsedTimeSpan,
+ message: message,
+ error: error)
+ ?? new CalibrateResult
+ {
+ Calibration = calibration,
+ ProfileKey = calibration?.ProfileKey ?? _drive?.DriveProfileKey ?? string.Empty,
+ CapacityReported = calibration?.CapacityReported ?? _drive?.Capacity ?? 0,
+ CapacityActual = calibration?.CapacityActual ?? 0,
+ EarlyWarning = calibration?.EarlyWarning,
+ EwToEomDistance = calibration?.EwToEomDistance ?? 0,
+ BytesTotal = _drive?.Capacity ?? 0,
+ BytesProcessed = calibration?.CapacityActual ?? 0,
+ WasAborted = aborted,
+ HasFailed = failed,
+ Success = !aborted && !failed && calibration is not null,
+ Outcome = aborted ? ServiceReportLevel.Failed
+ : failed ? ServiceReportLevel.Error
+ : ServiceReportLevel.Completed,
+ Duration = timer.ElapsedTimeSpan,
+ Message = message,
+ Error = error,
+ };
+
+ if (_drive is null || !_drive.IsMediaLoaded)
+ {
+ LastError = "Media not loaded";
+ throw new InvalidOperationException("Media not loaded");
+ }
+
+ try
+ {
+ LogWarn("Calibration is destructive — use a scratch cartridge only");
+ LogInfo("Preparing media for calibration...");
+ OnStatusUpdate("Preparing calibration...");
+
+ if (!_drive.PrepareMedia())
+ {
+ LastError = _drive.LastErrorMessage;
+ throw new InvalidOperationException($"Couldn't prepare media: {LastError}");
+ }
+
+ calibrator = new TapeCalibrator(_drive)
+ {
+ Options = request.Options,
+ };
+
+ progressHandler = CreateCalibrateProgressHandler(calibrator, request, _drive.Capacity);
+
+ using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
+ OperationCancellationToken,
+ request.Cancellation);
+ using var ctReg = linkedCancellation.Token.Register(() =>
+ {
+ if (calibrator is not null)
+ calibrator.IsAbortRequested = true;
+ });
+
+ LogInfo($"Calibration profile: >{_drive.DriveProfileKey}<");
+ LogInfoSub($"Reported capacity: {Helpers.BytesToStringLong(_drive.Capacity)}");
+
+ timer.Restart();
+ ITapeCalibration? calibration = calibrator.Run(progressHandler);
+ timer.Stop();
+
+ if (calibration is null)
+ {
+ LastError = calibrator.LastErrorMessage;
+ if (calibrator.LastError == (uint)WIN32_ERROR.ERROR_CANCELLED || calibrator.IsAbortRequested)
+ {
+ OnStatusUpdate("Calibration aborted");
+ LogFail("Calibration aborted");
+ return MakeResult(aborted: true, message: "Calibration aborted");
+ }
+
+ OnStatusUpdate("Calibration failed");
+ LogErr($"Calibration failed: {LastError}");
+ return MakeResult(failed: true, message: LastError);
+ }
+
+ OnStatusUpdate("Calibration complete");
+ LogInfo("Calibration summary:");
+ LogInfoSub($"Actual capacity: {Helpers.BytesToStringLong(calibration.CapacityActual)}");
+ if (calibration.EarlyWarning is { } ew)
+ {
+ LogInfoSub($"EW landmark: reported {Helpers.BytesToStringLong(ew.ReportedRemaining)}, " +
+ $"actual remaining {Helpers.BytesToStringLong(ew.ActualRemaining)}");
+ LogInfoSub($"EW→EOM distance: {Helpers.BytesToStringLong(calibration.EwToEomDistance)}");
+ }
+ else
+ {
+ LogInfoSub("EW landmark: not observed during calibration");
+ }
+ LogInfoSub($"Curve points: {calibration.Curve.Count:N0}");
+ LogOk("Calibration completed successfully");
+
+ progressHandler.CompleteProgress();
+ return MakeResult(calibration, message: "Calibration completed");
+ }
+ catch (TapeAbortRequestedException)
+ {
+ timer.Stop();
+ LastError = "Calibration aborted";
+ OnStatusUpdate("Calibration aborted");
+ LogFail("Calibration aborted");
+ return MakeResult(aborted: true, message: LastError);
+ }
+ catch (Exception ex)
+ {
+ timer.Stop();
+ LastError = ex.Message;
+ OnStatusUpdate("Calibration failed");
+ LogErr($"Calibration failed: {ex.Message}");
+ return MakeResult(failed: true, message: ex.Message, error: ex);
+ }
+ finally
+ {
+ progressHandler?.DisposeProgress();
+ }
+ }
+
+ ///
+ /// Creates the progress handler for a calibration run.
+ ///
+ protected virtual ServiceCalibrateProgressHandler CreateCalibrateProgressHandler(
+ TapeCalibrator calibrator,
+ CalibrateRequest request,
+ long capacityReported)
+ => new(_host, calibrator, capacityReported);
+
+ /// The current media profile key, or empty when no drive/media is available.
+ public string DriveProfileKey => _drive?.DriveProfileKey ?? string.Empty;
+
+ /// The active, matching calibration for the current media, or null.
+ public ITapeCalibration? Calibration => _drive?.Calibration;
+
+ /// Adds a calibration profile to the current drive. Returns whether it matches now.
+ public bool AddCalibration(ITapeCalibration calibration)
+ {
+ ArgumentNullException.ThrowIfNull(calibration);
+ if (_drive is null)
+ return false;
+ return _drive.AddCalibration(calibration);
+ }
+}
diff --git a/TapeWinNET/CalibrateWindow.xaml b/TapeWinNET/CalibrateWindow.xaml
new file mode 100644
index 0000000..4c36b83
--- /dev/null
+++ b/TapeWinNET/CalibrateWindow.xaml
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TapeWinNET/CalibrateWindow.xaml.cs b/TapeWinNET/CalibrateWindow.xaml.cs
new file mode 100644
index 0000000..8af4b65
--- /dev/null
+++ b/TapeWinNET/CalibrateWindow.xaml.cs
@@ -0,0 +1,52 @@
+using System.Windows;
+
+using TapeWinNET.Help;
+using TapeWinNET.ViewModels;
+
+namespace TapeWinNET;
+
+public partial class CalibrateWindow : Window, IHelpPaneHost
+{
+ private readonly DialogHelpPaneController _help;
+
+ public CalibrateWindow(CalibrationViewModel viewModel)
+ {
+ InitializeComponent();
+ DataContext = viewModel;
+
+ var icon = TapeIcons.GetTapeMediaIcon(large: true);
+ if (icon != null)
+ {
+ icon.Freeze();
+ Icon = icon;
+ }
+
+ _help = new DialogHelpPaneController(
+ this, this, HelpPaneColumn, HelpPaneSplitter, HelpPaneControl,
+ defaultTopicId: "dialog.calibrate-media", helpButton: HelpButton);
+ }
+
+ private void HelpButton_Click(object sender, RoutedEventArgs e)
+ => _help.ToggleHelpPane();
+
+ private void Window_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
+ => _help.HandleF1(e);
+
+ #region IHelpPaneHost
+
+ public string HostName => nameof(CalibrateWindow);
+
+ public HelpPaneHostMode HostMode => HelpPaneHostMode.Adjacent;
+
+ public void OnPaneOpening(double desiredWidth) => _help.OnPaneOpening(desiredWidth);
+
+ public void OnPaneClosed() => _help.OnPaneClosed();
+
+ public FrameworkElement? ResolveControlByName(string name)
+ => FindName(name) as FrameworkElement;
+
+ public void OpenHelpPane(string? topicId = null) => _help.OpenHelpPane(topicId);
+ public string? GetDefaultTopicId() => _help.GetDefaultTopicId();
+
+ #endregion
+}
diff --git a/TapeWinNET/CalibrationWindow.xaml b/TapeWinNET/CalibrationWindow.xaml
new file mode 100644
index 0000000..5b21935
--- /dev/null
+++ b/TapeWinNET/CalibrationWindow.xaml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TapeWinNET/CalibrationWindow.xaml.cs b/TapeWinNET/CalibrationWindow.xaml.cs
new file mode 100644
index 0000000..02161dc
--- /dev/null
+++ b/TapeWinNET/CalibrationWindow.xaml.cs
@@ -0,0 +1,52 @@
+using System.Windows;
+
+using TapeWinNET.Help;
+using TapeWinNET.ViewModels;
+
+namespace TapeWinNET;
+
+public partial class CalibrationWindow : Window, IHelpPaneHost
+{
+ private readonly DialogHelpPaneController _help;
+
+ public CalibrationWindow(CalibrationViewModel viewModel)
+ {
+ InitializeComponent();
+ DataContext = viewModel;
+
+ var icon = TapeIcons.GetTapeMediaIcon(large: true);
+ if (icon != null)
+ {
+ icon.Freeze();
+ Icon = icon;
+ }
+
+ _help = new DialogHelpPaneController(
+ this, this, HelpPaneColumn, HelpPaneSplitter, HelpPaneControl,
+ defaultTopicId: "dialog.calibration-result", helpButton: HelpButton);
+ }
+
+ private void HelpButton_Click(object sender, RoutedEventArgs e)
+ => _help.ToggleHelpPane();
+
+ private void Window_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
+ => _help.HandleF1(e);
+
+ #region IHelpPaneHost
+
+ public string HostName => nameof(CalibrationWindow);
+
+ public HelpPaneHostMode HostMode => HelpPaneHostMode.Adjacent;
+
+ public void OnPaneOpening(double desiredWidth) => _help.OnPaneOpening(desiredWidth);
+
+ public void OnPaneClosed() => _help.OnPaneClosed();
+
+ public FrameworkElement? ResolveControlByName(string name)
+ => FindName(name) as FrameworkElement;
+
+ public void OpenHelpPane(string? topicId = null) => _help.OpenHelpPane(topicId);
+ public string? GetDefaultTopicId() => _help.GetDefaultTopicId();
+
+ #endregion
+}
diff --git a/TapeWinNET/Controls/CalibrationCurveControl.xaml b/TapeWinNET/Controls/CalibrationCurveControl.xaml
new file mode 100644
index 0000000..9e8a5b3
--- /dev/null
+++ b/TapeWinNET/Controls/CalibrationCurveControl.xaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs b/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
new file mode 100644
index 0000000..588b3da
--- /dev/null
+++ b/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
@@ -0,0 +1,183 @@
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using System.Windows.Shapes;
+
+using Windows.Win32.System.SystemServices; // Helpers.BytesToStringLong
+
+using TapeLibNET;
+
+namespace TapeWinNET.Controls;
+
+///
+/// Plots the calibrated ReportedRemaining → ActualRemaining curve.
+///
+/// The X axis is intentionally flipped: full capacity on the left, EOM on the right.
+/// To magnify the small-but-critical EW→EOM tail, the chart uses a split axis: the span from
+/// BOT→EW occupies 80% of the width, and the EW→EOM tail occupies the remaining 20%.
+/// This preserves the overall shape while making the tail readable even when EW sits only a few
+/// percent from EOM.
+///
+///
+public partial class CalibrationCurveControl : UserControl
+{
+ private readonly Polyline _curveLine;
+ private readonly Rectangle _tailShade;
+ private readonly Ellipse _ewMarker;
+ private readonly Ellipse _eomMarker;
+ private readonly Line _ewGuide;
+
+ public static readonly DependencyProperty CalibrationProperty =
+ DependencyProperty.Register(
+ nameof(Calibration),
+ typeof(ITapeCalibration),
+ typeof(CalibrationCurveControl),
+ new PropertyMetadata(null, OnCalibrationChanged));
+
+ public ITapeCalibration? Calibration
+ {
+ get => (ITapeCalibration?)GetValue(CalibrationProperty);
+ set => SetValue(CalibrationProperty, value);
+ }
+
+ public CalibrationCurveControl()
+ {
+ InitializeComponent();
+
+ _tailShade = new Rectangle
+ {
+ Fill = new SolidColorBrush(Color.FromArgb(32, 255, 165, 0)),
+ IsHitTestVisible = false,
+ };
+
+ _curveLine = new Polyline
+ {
+ Stroke = WpfTheme.AccentBlueDarkBrush,
+ StrokeThickness = 2,
+ StrokeLineJoin = PenLineJoin.Round,
+ IsHitTestVisible = false,
+ };
+
+ _ewGuide = new Line
+ {
+ Stroke = Brushes.DarkOrange,
+ StrokeThickness = 1.5,
+ StrokeDashArray = new DoubleCollection { 4, 2 },
+ IsHitTestVisible = false,
+ Visibility = Visibility.Collapsed,
+ };
+
+ _ewMarker = new Ellipse
+ {
+ Width = 8,
+ Height = 8,
+ Fill = Brushes.DarkOrange,
+ Stroke = Brushes.White,
+ StrokeThickness = 1,
+ Visibility = Visibility.Collapsed,
+ IsHitTestVisible = false,
+ };
+
+ _eomMarker = new Ellipse
+ {
+ Width = 8,
+ Height = 8,
+ Fill = Brushes.Firebrick,
+ Stroke = Brushes.White,
+ StrokeThickness = 1,
+ IsHitTestVisible = false,
+ };
+
+ PlotCanvas.Children.Add(_tailShade);
+ PlotCanvas.Children.Add(_curveLine);
+ PlotCanvas.Children.Add(_ewGuide);
+ PlotCanvas.Children.Add(_ewMarker);
+ PlotCanvas.Children.Add(_eomMarker);
+
+ PlotCanvas.SizeChanged += (_, _) => Redraw();
+ }
+
+ private static void OnCalibrationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((CalibrationCurveControl)d).Redraw();
+
+ private void Redraw()
+ {
+ double w = PlotCanvas.ActualWidth;
+ double h = PlotCanvas.ActualHeight;
+ if (w < 2 || h < 2 || Calibration is null || Calibration.Curve.Count == 0)
+ {
+ _curveLine.Points.Clear();
+ _ewGuide.Visibility = Visibility.Collapsed;
+ _ewMarker.Visibility = Visibility.Collapsed;
+ _eomMarker.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ ITapeCalibration calibration = Calibration;
+ long reportedMax = Math.Max(1L, calibration.CapacityReported);
+ long actualMax = Math.Max(1L, calibration.CapacityActual);
+ long ewReported = calibration.EarlyWarning?.ReportedRemaining ?? 0L;
+
+ const double tailFraction = 0.20;
+ double bodyWidth = ewReported > 0 ? w * (1.0 - tailFraction) : w;
+ double tailWidth = ewReported > 0 ? w * tailFraction : 0.0;
+
+ Point MapPoint(CalibrationPoint point)
+ {
+ double x;
+ if (ewReported > 0 && point.ReportedRemaining <= ewReported)
+ {
+ double tTail = ewReported > 0 ? point.ReportedRemaining / (double)ewReported : 0.0;
+ x = bodyWidth + (1.0 - tTail) * tailWidth;
+ }
+ else
+ {
+ double topSpan = Math.Max(1L, reportedMax - ewReported);
+ double tBody = (point.ReportedRemaining - ewReported) / topSpan;
+ x = (1.0 - tBody) * bodyWidth;
+ }
+
+ double y = h - ((double)point.ActualRemaining / actualMax) * h;
+ return new Point(Math.Clamp(x, 0.0, w), Math.Clamp(y, 0.0, h));
+ }
+
+ _curveLine.Points = [.. calibration.Curve.Select(MapPoint)];
+
+ ActualTopLabel.Text = Helpers.BytesToStringLong(actualMax);
+ ActualBottomLabel.Text = "0";
+ ReportedLeftLabel.Text = Helpers.BytesToStringLong(reportedMax);
+ ReportedRightLabel.Text = "EOM";
+
+ if (ewReported > 0 && calibration.EarlyWarning is { } ew)
+ {
+ var ewPoint = MapPoint(ew);
+ _ewGuide.Visibility = Visibility.Visible;
+ _ewGuide.X1 = ewPoint.X;
+ _ewGuide.X2 = ewPoint.X;
+ _ewGuide.Y1 = 0;
+ _ewGuide.Y2 = h;
+
+ _ewMarker.Visibility = Visibility.Visible;
+ Canvas.SetLeft(_ewMarker, ewPoint.X - (_ewMarker.Width / 2));
+ Canvas.SetTop(_ewMarker, ewPoint.Y - (_ewMarker.Height / 2));
+
+ _tailShade.Visibility = Visibility.Visible;
+ _tailShade.Width = tailWidth;
+ _tailShade.Height = h;
+ Canvas.SetLeft(_tailShade, bodyWidth);
+ Canvas.SetTop(_tailShade, 0);
+ }
+ else
+ {
+ _ewGuide.Visibility = Visibility.Collapsed;
+ _ewMarker.Visibility = Visibility.Collapsed;
+ _tailShade.Visibility = Visibility.Collapsed;
+ }
+
+ var eomPoint = MapPoint(new CalibrationPoint(0, 0));
+ _eomMarker.Visibility = Visibility.Visible;
+ Canvas.SetLeft(_eomMarker, eomPoint.X - (_eomMarker.Width / 2));
+ Canvas.SetTop(_eomMarker, eomPoint.Y - (_eomMarker.Height / 2));
+ }
+}
diff --git a/TapeWinNET/MainWindow.xaml b/TapeWinNET/MainWindow.xaml
index 2d08f61..064e589 100644
--- a/TapeWinNET/MainWindow.xaml
+++ b/TapeWinNET/MainWindow.xaml
@@ -289,6 +289,8 @@
+
+
diff --git a/TapeWinNET/Services/TapeService.Calibration.cs b/TapeWinNET/Services/TapeService.Calibration.cs
new file mode 100644
index 0000000..27a3130
--- /dev/null
+++ b/TapeWinNET/Services/TapeService.Calibration.cs
@@ -0,0 +1,33 @@
+using TapeLibNET;
+using TapeLibNET.Services;
+
+namespace TapeWinNET.Services;
+
+///
+/// Partial class — calibration factory override for .
+/// All state-machine logic lives in ; this partial only adds the
+/// WPF-specific progress handler that drives the shared operation overlay.
+///
+public partial class TapeService
+{
+ ///
+ protected override ServiceCalibrateProgressHandler CreateCalibrateProgressHandler(
+ TapeCalibrator calibrator,
+ CalibrateRequest request,
+ long capacityReported)
+ => new GuiCalibrateProgressHandler((WpfServiceHost)_host, calibrator, capacityReported);
+
+ #region Helper Class — Calibration progress handler
+
+ private sealed class GuiCalibrateProgressHandler(
+ WpfServiceHost host,
+ TapeCalibrator calibrator,
+ long capacityReported)
+ : ServiceCalibrateProgressHandler(host, calibrator, capacityReported)
+ {
+ protected override void ReportProgress(TapeCalibrationProgress progress)
+ => host.UpdateCalibrateProgress(FilesProcessed, FilesTotal, BytesProcessed, BytesTotal, CurrentPhase);
+ }
+
+ #endregion
+}
diff --git a/TapeWinNET/Services/WpfServiceHost.cs b/TapeWinNET/Services/WpfServiceHost.cs
index d057ad5..bcff9cc 100644
--- a/TapeWinNET/Services/WpfServiceHost.cs
+++ b/TapeWinNET/Services/WpfServiceHost.cs
@@ -89,6 +89,22 @@ public void UpdateBackupProgress(int processed, int total, long bytes, long tota
setText: text => _viewModel.BackupProgressText = text,
filesSuffix: " files");
+ ///
+ /// Updates the calibration progress indicators on the bound .
+ /// Safe to call from any thread — marshals to the UI dispatcher internally.
+ ///
+ public void UpdateCalibrateProgress(int processed, int total, long bytesWritten, long estimatedCapacity, string phase)
+ {
+ _dispatcher.Invoke(() =>
+ {
+ _viewModel.CurrentCalibrationPhase = phase;
+ double progress = UpdateIOProgress(processed, total, bytesWritten, estimatedCapacity);
+ _viewModel.CalibrationProgressPercent = Math.Clamp(progress * 100.0, 0.0, 100.0);
+ _viewModel.CalibrationProgressText =
+ $"{Helpers.BytesToStringLong(bytesWritten)} of ~{Helpers.BytesToStringLong(estimatedCapacity)} written";
+ });
+ }
+
///
/// Shared implementation for and
/// — both operations report the same shape
diff --git a/TapeWinNET/ViewModels/CalibrationViewModel.cs b/TapeWinNET/ViewModels/CalibrationViewModel.cs
new file mode 100644
index 0000000..41580be
--- /dev/null
+++ b/TapeWinNET/ViewModels/CalibrationViewModel.cs
@@ -0,0 +1,201 @@
+using System.Windows;
+using System.Windows.Input;
+
+using Windows.Win32.System.SystemServices; // Helpers.BytesToStringLong
+
+using TapeLibNET;
+using TapeLibNET.Services;
+using TapeWinNET.Services;
+
+namespace TapeWinNET.ViewModels;
+
+///
+/// ViewModel for the calibration confirmation and result dialogs.
+/// Owns the destructive calibration run and the subsequent Save/Apply actions.
+///
+public sealed class CalibrationViewModel : ViewModelBase
+{
+ private readonly TapeService _tapeService;
+ private readonly Action _onStart;
+ private readonly Action _onCancel;
+ private readonly Action? _onApplied;
+ private readonly CancellationTokenSource _abortCts = new();
+
+ private bool _isConfirmChecked;
+ private bool _isSaved;
+ private bool _isApplied;
+ private string _statusMessage = string.Empty;
+ private CalibrateResult? _result;
+
+ public CalibrationViewModel(
+ TapeService tapeService,
+ Action onStart,
+ Action onCancel,
+ Action? onApplied = null)
+ {
+ _tapeService = tapeService;
+ _onStart = onStart;
+ _onCancel = onCancel;
+ _onApplied = onApplied;
+
+ StartCommand = new RelayCommand(_ => _onStart(this), _ => IsConfirmChecked);
+ CancelCommand = new RelayCommand(_ => _onCancel());
+ SaveProfileCommand = new RelayCommand(_ => SaveProfile(), _ => Result?.Calibration is not null && !IsSaved);
+ ApplyProfileCommand = new RelayCommand(_ => ApplyProfile(), _ => Result?.Calibration is not null && !IsApplied);
+ }
+
+ #region Confirmation
+
+ public bool IsConfirmChecked
+ {
+ get => _isConfirmChecked;
+ set
+ {
+ if (SetProperty(ref _isConfirmChecked, value))
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+
+ public string Vendor => string.IsNullOrWhiteSpace(_tapeService.DeviceVendor) ? "Unknown" : _tapeService.DeviceVendor;
+ public string Product => string.IsNullOrWhiteSpace(_tapeService.DeviceProduct) ? "Unknown" : _tapeService.DeviceProduct;
+ public string Revision => string.IsNullOrWhiteSpace(_tapeService.DeviceRevision) ? "Unknown" : _tapeService.DeviceRevision;
+ public string ProfileKey => string.IsNullOrWhiteSpace(_tapeService.DriveProfileKey) ? "(unknown)" : _tapeService.DriveProfileKey;
+ public string CapacityDisplay => Helpers.BytesToStringLong(_tapeService.Capacity);
+ public string CapacityBucketDisplay => $"{TapeCalibration.CapacityBucketGB(_tapeService.Capacity):N0} GB bucket";
+ public WarningLevel WarningLevel => WarningLevel.Error;
+ public string WarningMessage =>
+ "Calibration writes the scratch cartridge to end-of-media and destroys any existing content.\r\n" +
+ "Use only expendable media dedicated to calibration.";
+
+ #endregion
+
+ #region Result
+
+ public CalibrateResult? Result
+ {
+ get => _result;
+ private set
+ {
+ if (!SetProperty(ref _result, value))
+ return;
+
+ OnPropertyChanged(nameof(Calibration));
+ OnPropertyChanged(nameof(CapacityReportedDisplay));
+ OnPropertyChanged(nameof(CapacityActualDisplay));
+ OnPropertyChanged(nameof(EarlyWarningDisplay));
+ OnPropertyChanged(nameof(EwToEomDistanceDisplay));
+ OnPropertyChanged(nameof(CurvePointCountDisplay));
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+
+ public ITapeCalibration? Calibration => Result?.Calibration;
+
+ public string CapacityReportedDisplay =>
+ Result is not null ? Helpers.BytesToStringLong(Result.CapacityReported) : "—";
+
+ public string CapacityActualDisplay =>
+ Result is not null ? Helpers.BytesToStringLong(Result.CapacityActual) : "—";
+
+ public string EarlyWarningDisplay =>
+ Result?.EarlyWarning is { } ew
+ ? $"{Helpers.BytesToStringLong(ew.ActualRemaining)} remaining (reported {Helpers.BytesToStringLong(ew.ReportedRemaining)})"
+ : "Not observed";
+
+ public string EwToEomDistanceDisplay =>
+ Result is not null && Result.EwToEomDistance > 0
+ ? Helpers.BytesToStringLong(Result.EwToEomDistance)
+ : "—";
+
+ public string CurvePointCountDisplay =>
+ Result is not null ? Result.CurvePointCount.ToString("N0") : "0";
+
+ public bool IsSaved
+ {
+ get => _isSaved;
+ private set
+ {
+ if (SetProperty(ref _isSaved, value))
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+
+ public bool IsApplied
+ {
+ get => _isApplied;
+ private set
+ {
+ if (SetProperty(ref _isApplied, value))
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+
+ public string StatusMessage
+ {
+ get => _statusMessage;
+ private set => SetProperty(ref _statusMessage, value);
+ }
+
+ #endregion
+
+ #region Commands
+
+ public ICommand StartCommand { get; }
+ public ICommand CancelCommand { get; }
+ public ICommand SaveProfileCommand { get; }
+ public ICommand ApplyProfileCommand { get; }
+
+ #endregion
+
+ #region Operations
+
+ public async Task RunAsync()
+ {
+ Result = await _tapeService.ExecuteCalibrateAsync(
+ new CalibrateRequest(
+ EjectWhenDone: false,
+ Options: new TapeCalibrationOptions())
+ {
+ Cancellation = _abortCts.Token,
+ OperationLabel = "Calibration",
+ });
+
+ return Result;
+ }
+
+ public void RequestAbort() => _abortCts.Cancel();
+
+ private void SaveProfile()
+ {
+ if (Calibration is null)
+ return;
+
+ if (!App.Settings.Calibrations.Save(Calibration))
+ {
+ SimpleBox.Show(
+ $"Failed to save the calibration profile.\n\n{App.Settings.Calibrations.LastErrorMessage}",
+ "Save Calibration",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ return;
+ }
+
+ IsSaved = true;
+ StatusMessage = "Calibration profile saved.";
+ }
+
+ private void ApplyProfile()
+ {
+ if (Calibration is null)
+ return;
+
+ bool matched = _tapeService.AddCalibration(Calibration);
+ IsApplied = true;
+ StatusMessage = matched
+ ? "Calibration profile applied to the current media."
+ : "Calibration profile loaded, but it does not match the current media.";
+ _onApplied?.Invoke();
+ }
+
+ #endregion
+}
diff --git a/TapeWinNET/ViewModels/MainViewModel.Calibration.cs b/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
new file mode 100644
index 0000000..dfd0f2d
--- /dev/null
+++ b/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
@@ -0,0 +1,202 @@
+using System.Linq;
+using System.Windows;
+using System.Windows.Input;
+
+using TapeLibNET.Services;
+
+namespace TapeWinNET.ViewModels;
+
+///
+/// Partial class containing calibration-related functionality for .
+///
+public partial class MainViewModel
+{
+ #region Calibration Fields
+
+ private double _calibrationProgressPercent;
+ private string _calibrationProgressText = string.Empty;
+ private string _currentCalibrationPhase = string.Empty;
+ private bool _isCalibrateInProgress;
+ private bool _isAbortCalibrationEnabled = true;
+ private CalibrationViewModel? _activeCalibrationViewModel;
+
+ #endregion
+
+ #region Calibration Properties
+
+ public bool IsCalibrateInProgress
+ {
+ get => _isCalibrateInProgress;
+ set
+ {
+ if (SetProperty(ref _isCalibrateInProgress, value))
+ {
+ OnPropertyChanged(nameof(IsGeneralBusy));
+ OnPropertyChanged(nameof(IsOperationInProgress));
+ OnPropertyChanged(nameof(IsMediaBrowsingEnabled));
+ NotifyOperationPropertiesChanged();
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+ }
+
+ public double CalibrationProgressPercent
+ {
+ get => _calibrationProgressPercent;
+ set
+ {
+ if (SetProperty(ref _calibrationProgressPercent, value))
+ OnPropertyChanged(nameof(OperationProgressPercent));
+ }
+ }
+
+ public string CalibrationProgressText
+ {
+ get => _calibrationProgressText;
+ set
+ {
+ if (SetProperty(ref _calibrationProgressText, value))
+ OnPropertyChanged(nameof(OperationProgressText));
+ }
+ }
+
+ public string CurrentCalibrationPhase
+ {
+ get => _currentCalibrationPhase;
+ set
+ {
+ if (SetProperty(ref _currentCalibrationPhase, value))
+ OnPropertyChanged(nameof(CurrentOperationFile));
+ }
+ }
+
+ public bool IsAbortCalibrationEnabled
+ {
+ get => _isAbortCalibrationEnabled;
+ set
+ {
+ if (SetProperty(ref _isAbortCalibrationEnabled, value))
+ OnPropertyChanged(nameof(IsAbortOperationEnabled));
+ }
+ }
+
+ #endregion
+
+ #region Calibration Commands
+
+ public ICommand CalibrateMediaCommand { get; private set; } = null!;
+ public ICommand AbortCalibrationCommand { get; private set; } = null!;
+
+ private void InitializeCalibrationCommands()
+ {
+ CalibrateMediaCommand = new RelayCommand(ShowCalibrationWindow, _ => !IsBusy && _tapeService.IsMediaLoaded);
+ AbortCalibrationCommand = new RelayCommand(AbortCalibration, _ => IsCalibrateInProgress);
+ }
+
+ #endregion
+
+ #region Private Methods - Calibration Operations
+
+ private void ShowCalibrationWindow(object? parameter)
+ {
+ var viewModel = new CalibrationViewModel(
+ _tapeService,
+ OnStartCalibration,
+ () => Application.Current.Windows.OfType().FirstOrDefault()?.Close(),
+ onApplied: RefreshCurrentView);
+
+ var window = new CalibrateWindow(viewModel)
+ {
+ Owner = Application.Current.MainWindow
+ };
+ window.ShowDialog();
+ }
+
+ private void OnStartCalibration(CalibrationViewModel viewModel)
+ {
+ Application.Current.Windows.OfType().FirstOrDefault()?.Close();
+ _ = ExecuteCalibrationAsync(viewModel);
+ }
+
+ private async Task ExecuteCalibrationAsync(CalibrationViewModel viewModel)
+ {
+ IsBusy = true;
+ IsCalibrateInProgress = true;
+ IsAbortCalibrationEnabled = true;
+ BusyMessage = "Preparing calibration...";
+ CalibrationProgressPercent = 0;
+ CalibrationProgressText = "Starting...";
+ CurrentCalibrationPhase = string.Empty;
+ _activeCalibrationViewModel = viewModel;
+
+ try
+ {
+ var operationResult = await viewModel.RunAsync();
+
+ if (operationResult is { HasFailed: true })
+ {
+ SimpleBox.Show("Calibration failed. See log for details.", "Calibration Failed",
+ MessageBoxButton.OK, MessageBoxImage.Error);
+ return;
+ }
+
+ if (operationResult is { WasAborted: true })
+ {
+ SimpleBox.Show("Calibration was aborted.", "Calibration Aborted",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ _activeCalibrationViewModel = null;
+ IsCalibrateInProgress = false;
+ IsAbortCalibrationEnabled = true;
+ IsBusy = false;
+ BusyMessage = string.Empty;
+ CalibrationProgressText = string.Empty;
+ CurrentCalibrationPhase = string.Empty;
+
+ var resultWindow = new CalibrationWindow(viewModel)
+ {
+ Owner = Application.Current.MainWindow
+ };
+ resultWindow.ShowDialog();
+ }
+ catch (Exception ex)
+ {
+ LogErr($"Calibration failed: {ex.Message}");
+ SimpleBox.Show($"Calibration failed.\n\n{ex.Message}", "Calibration Error",
+ MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ finally
+ {
+ _activeCalibrationViewModel = null;
+ IsCalibrateInProgress = false;
+ IsAbortCalibrationEnabled = true;
+ IsBusy = false;
+ BusyMessage = string.Empty;
+ CalibrationProgressText = string.Empty;
+ CurrentCalibrationPhase = string.Empty;
+ }
+ }
+
+ private void AbortCalibration(object? parameter)
+ {
+ if (_activeCalibrationViewModel is null)
+ return;
+
+ var result = SimpleBox.Show(
+ "Are you sure you want to abort the calibration?\n\nThe scratch media may already be partially written.",
+ "Abort Calibration",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning);
+
+ if (result != MessageBoxResult.Yes)
+ return;
+
+ _activeCalibrationViewModel.RequestAbort();
+ IsAbortCalibrationEnabled = false;
+ BusyMessage = "Aborting calibration...";
+ }
+
+ #endregion
+}
diff --git a/TapeWinNET/ViewModels/MainViewModel.cs b/TapeWinNET/ViewModels/MainViewModel.cs
index 25df763..d8ba18c 100644
--- a/TapeWinNET/ViewModels/MainViewModel.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.cs
@@ -111,6 +111,9 @@ public MainViewModel()
// Initialize backup commands (from MainViewModel.Backup.cs)
InitializeBackupCommands();
+ // Initialize calibration commands (from MainViewModel.Calibration.cs)
+ InitializeCalibrationCommands();
+
// Initialize restore commands (from MainViewModel.Restore.cs)
InitializeRestoreCommands();
@@ -273,14 +276,14 @@ private set
public bool IsTOCCancelEnabled => !_isTOCAbortPending;
///
- /// True when busy with non-backup/restore/TOC-load operations (shows full-window overlay).
+ /// True when busy with non-backup/restore/calibration/TOC-load operations (shows full-window overlay).
///
- public bool IsGeneralBusy => IsBusy && !IsBackupInProgress && !IsRestoreInProgress && !IsTOCLoadInProgress;
+ public bool IsGeneralBusy => IsBusy && !IsBackupInProgress && !IsRestoreInProgress && !IsCalibrateInProgress && !IsTOCLoadInProgress;
///
- /// True when any tape operation (backup or restore/validate/verify) is in progress.
+ /// True when any tape operation (backup, calibration, or restore/validate/verify) is in progress.
///
- public bool IsOperationInProgress => IsBackupInProgress || IsRestoreInProgress;
+ public bool IsOperationInProgress => IsBackupInProgress || IsCalibrateInProgress || IsRestoreInProgress;
///
/// False whenever any operation/busy overlay is shown, so the TreeView and the media/property
@@ -297,6 +300,7 @@ private set
public bool IsMediaBrowsingEnabled => !IsBusy && !IsOperationInProgress && !IsTOCLoadInProgress;
// BackupProgressPercent, BackupProgressText, CurrentBackupFile properties are in MainViewModel.Backup.cs
+ // CalibrationProgressPercent, CalibrationProgressText, CurrentCalibrationPhase properties are in MainViewModel.Calibration.cs
// RestoreProgressPercent, RestoreProgressText, CurrentRestoreFile, IsRestoreInProgress properties are in MainViewModel.Restore.cs
// ── Unified Operation overlay ─────────────────────────────────────────────
@@ -305,23 +309,35 @@ private set
// bar, current file, IO sparkline, abort button). These properties pick the
// currently active operation's values, since only one operation runs at a time.
- /// Progress percent of whichever operation (backup or restore) is currently active.
- public double OperationProgressPercent => IsBackupInProgress ? BackupProgressPercent : RestoreProgressPercent;
+ /// Progress percent of whichever operation is currently active.
+ public double OperationProgressPercent => IsBackupInProgress ? BackupProgressPercent
+ : IsCalibrateInProgress ? CalibrationProgressPercent
+ : RestoreProgressPercent;
- /// Progress text of whichever operation (backup or restore) is currently active.
- public string OperationProgressText => IsBackupInProgress ? BackupProgressText : RestoreProgressText;
+ /// Progress text of whichever operation is currently active.
+ public string OperationProgressText => IsBackupInProgress ? BackupProgressText
+ : IsCalibrateInProgress ? CalibrationProgressText
+ : RestoreProgressText;
- /// Current file name of whichever operation (backup or restore) is currently active.
- public string CurrentOperationFile => IsBackupInProgress ? CurrentBackupFile : CurrentRestoreFile;
+ /// Current file name / phase text of whichever operation is currently active.
+ public string CurrentOperationFile => IsBackupInProgress ? CurrentBackupFile
+ : IsCalibrateInProgress ? CurrentCalibrationPhase
+ : CurrentRestoreFile;
- /// Abort command of whichever operation (backup or restore) is currently active.
- public ICommand AbortOperationCommand => IsBackupInProgress ? AbortBackupCommand : AbortRestoreCommand;
+ /// Abort command of whichever operation is currently active.
+ public ICommand AbortOperationCommand => IsBackupInProgress ? AbortBackupCommand
+ : IsCalibrateInProgress ? AbortCalibrationCommand
+ : AbortRestoreCommand;
- /// Abort button IsEnabled state of whichever operation (backup or restore) is currently active.
- public bool IsAbortOperationEnabled => IsBackupInProgress ? IsAbortBackupEnabled : IsAbortRestoreEnabled;
+ /// Abort button IsEnabled state of whichever operation is currently active.
+ public bool IsAbortOperationEnabled => IsBackupInProgress ? IsAbortBackupEnabled
+ : IsCalibrateInProgress ? IsAbortCalibrationEnabled
+ : IsAbortRestoreEnabled;
- /// Abort button label — distinguishes the two operations for clarity.
- public string AbortOperationButtonText => IsBackupInProgress ? "Abort Backup" : "Abort";
+ /// Abort button label — distinguishes the operations for clarity.
+ public string AbortOperationButtonText => IsBackupInProgress ? "Abort Backup"
+ : IsCalibrateInProgress ? "Abort Calibration"
+ : "Abort";
///
/// Raises change notifications for all unified Operation-overlay properties.
@@ -666,6 +682,7 @@ private void NotifyFilterPropertiesChanged()
public ICommand ImportTOCCommand { get; }
public ICommand AbortTOCLoadCommand { get; private set; } = null!;
// NewBackupCommand and AbortBackupCommand are in MainViewModel.Backup.cs
+ // CalibrateMediaCommand and AbortCalibrationCommand are in MainViewModel.Calibration.cs
// RestoreCommand, ValidateCommand, VerifyCommand, AbortRestoreCommand are in MainViewModel.Restore.cs
public ICommand NavigateToBackupSetCommand { get; }
@@ -2149,4 +2166,3 @@ private async Task FormatVirtualDriveAsync(FormatMediaViewModel formatViewModel)
#endregion
}
-
From 428eaa2cce125f46ca1f5c7b61d96ac59308750e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 1 Aug 2026 06:34:04 +0000
Subject: [PATCH 03/37] fix calibration overreport result and curve layout
---
.../CalibrationAndLogicalEwTests.cs | 29 +++++++++++
.../Services/ServiceCalibrationTests.cs | 32 +++++++++++-
TapeLibNET/Services/ServiceOperationResult.cs | 6 ++-
TapeLibNET/TapeCalibration.cs | 14 ++++--
TapeLibNET/TapeCalibrator.cs | 19 ++++---
.../Controls/CalibrationCurveControl.xaml | 50 ++++++++++++-------
.../ViewModels/OpenVirtualDriveViewModel.cs | 2 +
.../VirtualDriveConfigViewModelBase.cs | 6 ++-
8 files changed, 127 insertions(+), 31 deletions(-)
diff --git a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
index ff1521c..cf4ef5a 100644
--- a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
+++ b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
@@ -114,6 +114,35 @@ public void CalibrationRun_RestoresPriorReserveAndCalibrations()
Assert.Contains(preloaded, drive.Calibrations);
}
+ [Fact]
+ public void CalibrationRun_WithOverreport_RecordsReportedCapacityAboveActualCapacity()
+ {
+ var profile = VirtualTapeEwProfile.Lto4Like(Capacity, ewZonePercent: 4.0, floorPercent: 10.0);
+ var (drive, _) = CreateDrive(profile);
+
+ var calibrator = new TapeCalibrator(drive)
+ {
+ Options = new TapeCalibrationOptions
+ {
+ SampleCount = 40,
+ MinSampleInterval = 1L * 1024 * 1024,
+ ChunkBytesTarget = 1L * 1024 * 1024,
+ },
+ };
+
+ ITapeCalibration? cal = calibrator.Run();
+ Assert.NotNull(cal);
+
+ // TrueRemaining still drives hard EOM at the cartridge's real capacity, while the emulated
+ // driver continues to over-report phantom free space in the tail.
+ Assert.InRange(cal!.CapacityActual, (long)(Capacity * 0.98), Capacity);
+ Assert.True(cal.CapacityReported > cal.CapacityActual,
+ "Calibration should preserve the driver's optimistic reported-capacity side");
+ Assert.True(cal.Curve[0].ReportedRemaining > 0,
+ "Hard EOM should still leave a positive driver-reported remaining value when overreport is enabled");
+ Assert.Equal(0L, cal.Curve[0].ActualRemaining);
+ }
+
#endregion
#region *** Persistence ***
diff --git a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
index ee2029e..1ec1f5a 100644
--- a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
+++ b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
@@ -11,7 +11,8 @@ public class ServiceCalibrationTests : ServiceTestBase
private static async Task<(TapeServiceBase service, TestTapeServiceHost host)> OpenCalibrationServiceAsync(
long capacity = CalibrationCapacity,
- VirtualTapeDriveIoRate? ioRate = null)
+ VirtualTapeDriveIoRate? ioRate = null,
+ VirtualTapeEwProfile? ewProfile = null)
{
var (service, host) = CreateService();
@@ -21,7 +22,7 @@ public class ServiceCalibrationTests : ServiceTestBase
VirtualTapeDriveCapabilities.WithFilemarksOnlyLargeBlocks,
vmd,
ioRate: ioRate,
- ewProfile: VirtualTapeEwProfile.Lto4Like(capacity)),
+ ewProfile: ewProfile ?? VirtualTapeEwProfile.Lto4Like(capacity)),
$"OpenVirtualDriveAsync failed: {service.LastError}");
Assert.True(await service.LoadMediaAsync(),
@@ -50,6 +51,7 @@ public async Task ExecuteCalibrateAsync_ReturnsCalibrationAndLogsSummary()
Assert.False(result.WasAborted);
Assert.NotNull(result.Calibration);
Assert.Equal(service.DriveProfileKey, result.ProfileKey);
+ Assert.True(result.CapacityReported > result.CapacityActual);
Assert.True(result.CapacityActual > 0);
Assert.True(result.EwToEomDistance > 0);
Assert.Contains(ServiceStateChange.OperationStarted, host.StateChanges);
@@ -92,4 +94,30 @@ public async Task ExecuteCalibrateAsync_HonorsAbortRequest()
Assert.True(host.ContainsMessage("Calibration abort requested"));
}
}
+
+ [Fact]
+ public async Task ExecuteCalibrateAsync_WithCustomOverreport_ExposesReportedCapacityGap()
+ {
+ var (service, _) = await OpenCalibrationServiceAsync(
+ ewProfile: VirtualTapeEwProfile.Lto4Like(CalibrationCapacity, ewZonePercent: 4.0, floorPercent: 10.0));
+
+ using (service)
+ {
+ var result = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(
+ EjectWhenDone: false,
+ Options: new TapeCalibrationOptions
+ {
+ SampleCount = 20,
+ MinSampleInterval = 1L * MB,
+ ChunkBytesTarget = 1L * MB,
+ }));
+
+ Assert.True(result.Success);
+ Assert.True(result.CapacityReported > result.CapacityActual);
+ Assert.NotNull(result.Calibration);
+ Assert.True(result.Calibration!.Curve[0].ReportedRemaining > 0);
+ Assert.Equal(0L, result.Calibration.Curve[0].ActualRemaining);
+ }
+ }
}
diff --git a/TapeLibNET/Services/ServiceOperationResult.cs b/TapeLibNET/Services/ServiceOperationResult.cs
index 8238554..e38a37b 100644
--- a/TapeLibNET/Services/ServiceOperationResult.cs
+++ b/TapeLibNET/Services/ServiceOperationResult.cs
@@ -128,7 +128,11 @@ public sealed record CalibrateResult : FileOperationResult
/// Matched drive+media profile key for this run.
public string ProfileKey { get; init; } = string.Empty;
- /// Driver-reported capacity at BOT (bytes).
+ ///
+ /// Effective driver-reported capacity (bytes): the largest total capacity implied by the driver's
+ /// reported remaining values during calibration, including any phantom free space it still claims
+ /// at hard EOM.
+ ///
public long CapacityReported { get; init; }
/// True raw capacity measured at hard EOM (bytes).
diff --git a/TapeLibNET/TapeCalibration.cs b/TapeLibNET/TapeCalibration.cs
index f8cdff1..2eee477 100644
--- a/TapeLibNET/TapeCalibration.cs
+++ b/TapeLibNET/TapeCalibration.cs
@@ -33,7 +33,11 @@ public interface ITapeCalibration
///
string ProfileKey { get; }
- /// Driver-reported capacity at BOT (bytes).
+ ///
+ /// Effective driver-reported capacity (bytes): the largest total capacity implied by the driver's
+ /// reported remaining values during calibration, including any phantom free space it still claims
+ /// at hard EOM.
+ ///
long CapacityReported { get; }
/// True raw capacity measured as bytes written at hard EOM (bytes) — the ground truth.
@@ -121,18 +125,22 @@ private TapeCalibration(
/// curve using (bytes at hard EOM): ActualRemaining = CapacityActual − ActualWritten.
///
/// Usually so a fresh run always matches.
- /// Driver capacity at BOT.
+ /// Driver-reported remaining at BOT.
/// Bytes written at hard EOM (ground truth).
/// The (ActualWritten, ReportedRemaining) pairs, including the EOM point.
/// The (ActualWritten, ReportedRemaining) at first EW, or null if none.
public static TapeCalibration FromMeasurements(
- string profileKey, long capacityReported, long capacityActual,
+ string profileKey, long capacityReportedAtBot, long capacityActual,
IEnumerable<(long ActualWritten, long ReportedRemaining)> rawSamples,
(long ActualWritten, long ReportedRemaining)? earlyWarning)
{
var pts = new List();
+ long capacityReported = Math.Max(0L, capacityReportedAtBot);
foreach (var (aw, rr) in rawSamples)
+ {
pts.Add(new CalibrationPoint(rr, Math.Max(0L, capacityActual - aw)));
+ capacityReported = Math.Max(capacityReported, aw + rr);
+ }
// Sort ascending by ReportedRemaining; on ties keep the CONSERVATIVE (smallest) ActualRemaining.
pts.Sort(static (a, b) =>
diff --git a/TapeLibNET/TapeCalibrator.cs b/TapeLibNET/TapeCalibrator.cs
index d4c03f7..6f0b066 100644
--- a/TapeLibNET/TapeCalibrator.cs
+++ b/TapeLibNET/TapeCalibrator.cs
@@ -124,8 +124,6 @@ public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder 0
- ? Math.Max(capacityReported / Math.Max(1, Options.SampleCount), Options.MinSampleInterval)
+ long sampleInterval = capacityReportedAtBot > 0
+ ? Math.Max(capacityReportedAtBot / Math.Max(1, Options.SampleCount), Options.MinSampleInterval)
: Options.MinSampleInterval;
m_logger.LogInformation(
"{Prefix}: Calibration start — profile '{Key}', reportedCapacity {Cap}, blockSize {Bs}, chunk {Chunk}, sampleInterval {Int}",
- LogPrefix, Drive.DriveProfileKey, capacityReported, blockSize, chunkBytes, sampleInterval);
+ LogPrefix, Drive.DriveProfileKey, capacityReportedAtBot, blockSize, chunkBytes, sampleInterval);
// --- Write to hard EOM, sampling as we go ---
var samples = new List<(long ActualWritten, long ReportedRemaining)>();
@@ -158,6 +161,8 @@ public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder 0 ? 100.0 * capacityActual / capacityReported : 0.0,
+ calibration.CapacityReported > 0 ? 100.0 * capacityActual / calibration.CapacityReported : 0.0,
ewPoint is { } e ? $"{e.ActualWritten} bytes / RR {e.ReportedRemaining}" : "(none)",
samples.Count);
diff --git a/TapeWinNET/Controls/CalibrationCurveControl.xaml b/TapeWinNET/Controls/CalibrationCurveControl.xaml
index 9e8a5b3..08e1383 100644
--- a/TapeWinNET/Controls/CalibrationCurveControl.xaml
+++ b/TapeWinNET/Controls/CalibrationCurveControl.xaml
@@ -5,18 +5,22 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignHeight="220"
- d:DesignWidth="420">
-
-
-
-
+ d:DesignWidth="420"
+ MinHeight="220"
+ ClipToBounds="True">
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+ FontSize="11"/>
+ FontSize="11"/>
diff --git a/TapeWinNET/ViewModels/OpenVirtualDriveViewModel.cs b/TapeWinNET/ViewModels/OpenVirtualDriveViewModel.cs
index c94e7f9..d06ff9d 100644
--- a/TapeWinNET/ViewModels/OpenVirtualDriveViewModel.cs
+++ b/TapeWinNET/ViewModels/OpenVirtualDriveViewModel.cs
@@ -192,6 +192,8 @@ public sealed record EwProfileOption(string Display, bool EnableEw, ITapeCalibra
/// Builds the emulation profile for a target . For
/// the caller passes explicit / ; for the
/// LTO-4 preset those are derived as percentages; for calibration options they are taken from the profile.
+ /// models phantom free space the driver still claims at hard EOM —
+ /// it does not reduce the medium's true writable capacity.
/// Returns when no meaningful emulation is configured (e.g. Custom with zero zone).
///
public VirtualTapeEwProfile? BuildProfile(long capacityBytes, long ewZoneBytes, long overreportBytes)
diff --git a/TapeWinNET/ViewModels/VirtualDriveConfigViewModelBase.cs b/TapeWinNET/ViewModels/VirtualDriveConfigViewModelBase.cs
index efb5ccd..27c28c8 100644
--- a/TapeWinNET/ViewModels/VirtualDriveConfigViewModelBase.cs
+++ b/TapeWinNET/ViewModels/VirtualDriveConfigViewModelBase.cs
@@ -265,7 +265,11 @@ public CapacityUnit OverreportUnit
/// EW-zone size resolved to bytes against the current content capacity.
public long EwZoneBytes => _ewZoneUnit.ToBytes(_ewZoneValue, ContentCapacityBytes);
- /// Capacity-overreport (floor) size resolved to bytes against the current content capacity.
+ ///
+ /// Capacity-overreport size resolved to bytes against the current content capacity — i.e. the
+ /// phantom free space the emulated driver may still claim at hard EOM, not a reduction of the
+ /// medium's true writable capacity.
+ ///
public long OverreportBytes => _overreportUnit.ToBytes(_overreportValue, ContentCapacityBytes);
public string EwZoneBytesDisplay =>
From 5944a29ca63734b7e3952f98e4920bba97743548 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 1 Aug 2026 07:09:30 +0000
Subject: [PATCH 04/37] Scale calibration chart to fit dialog frame
---
TapeWinNET/Controls/CalibrationCurveControl.xaml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/TapeWinNET/Controls/CalibrationCurveControl.xaml b/TapeWinNET/Controls/CalibrationCurveControl.xaml
index 08e1383..352387b 100644
--- a/TapeWinNET/Controls/CalibrationCurveControl.xaml
+++ b/TapeWinNET/Controls/CalibrationCurveControl.xaml
@@ -4,9 +4,9 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
- d:DesignHeight="220"
+ d:DesignHeight="160"
d:DesignWidth="420"
- MinHeight="220"
+ MinHeight="160"
ClipToBounds="True">
From 99969b4092557e165a5126ffce9762d287f1201c Mon Sep 17 00:00:00 2001
From: Alex K
Date: Fri, 7 Aug 2026 01:45:03 +0200
Subject: [PATCH 05/37] Streamline semantics of Remaining Capacity API and UI,
per Part 5 of Design-RemainingAndEw.md.
---
.../CalibrationAndLogicalEwTests.cs | 44 +-
.../Services/ServiceCalibrationTests.cs | 13 +-
TapeLibNET.Tests/VirtualDriveBasicTests.cs | 4 +-
.../ServiceOperationProgressHandler.cs | 3 +-
TapeLibNET/Services/ServiceOperationResult.cs | 22 +-
TapeLibNET/Services/TapeServiceBase.Backup.cs | 2 +-
TapeLibNET/Services/TapeServiceBase.EW.cs | 55 ++-
TapeLibNET/Services/TapeServiceBase.List.cs | 11 +-
TapeLibNET/Services/TapeServiceBase.cs | 164 ++++++--
TapeLibNET/TapeBackupAgent.cs | 12 +-
TapeLibNET/TapeCalibration.cs | 100 +++--
TapeLibNET/TapeCalibrator.cs | 28 +-
TapeLibNET/TapeDrive.cs | 36 +-
TapeLibNET/TapeDriveBackend.cs | 23 +-
TapeLibNET/TapeNavigator.cs | 52 ---
TapeLibNET/Virtual/VirtualTapeDriveBackend.cs | 8 +-
TapeLibNET/Virtual/VirtualTapeEwProfile.cs | 104 +++--
TapeWinNET/CalibrationWindow.xaml | 16 +-
.../Controls/CalibrationCurveControl.xaml.cs | 4 +-
TapeWinNET/MainWindow.xaml | 4 +
TapeWinNET/OpenVirtualDriveWindow.xaml | 36 +-
.../BackupMediaUsageBarPresenter.cs | 13 +-
TapeWinNET/ViewModels/CalibrationViewModel.cs | 18 +-
.../ViewModels/DeleteBackupSetsViewModel.cs | 2 +-
TapeWinNET/ViewModels/MainViewModel.cs | 66 ++-
.../ViewModels/MediaUsageBarPresenter.cs | 12 +-
.../ViewModels/OpenVirtualDriveViewModel.cs | 30 +-
.../VirtualDriveConfigViewModelBase.cs | 69 +++-
docs/Design-RemainingAndEw.md | 388 ++++++++++++++----
docs/TapeNET-Context-Primer.md | 6 +
30 files changed, 982 insertions(+), 363 deletions(-)
diff --git a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
index cf4ef5a..57c62c8 100644
--- a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
+++ b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
@@ -114,10 +114,18 @@ public void CalibrationRun_RestoresPriorReserveAndCalibrations()
Assert.Contains(preloaded, drive.Calibrations);
}
- [Fact]
- public void CalibrationRun_WithOverreport_RecordsReportedCapacityAboveActualCapacity()
+ [Theory]
+ // phantomFreePercent, reportedBoostPercent — the two INDEPENDENT over-report axes.
+ [InlineData(10.0, 0.0)] // faithful LTO shape: truthful at BOM, phantom free space at EOM
+ [InlineData(0.0, 10.0)] // inflated capacity at BOM only: constant overshoot, honest at EOM
+ [InlineData(10.0, 5.0)] // both axes at once
+ public void CalibrationRun_WithOverreport_CapturesBothBomAndEomAnchors(
+ double phantomFreePercent, double reportedBoostPercent)
{
- var profile = VirtualTapeEwProfile.Lto4Like(Capacity, ewZonePercent: 4.0, floorPercent: 10.0);
+ var profile = VirtualTapeEwProfile.Lto4Like(
+ Capacity, ewZonePercent: 4.0,
+ phantomFreePercent: phantomFreePercent,
+ reportedBoostPercent: reportedBoostPercent);
var (drive, _) = CreateDrive(profile);
var calibrator = new TapeCalibrator(drive)
@@ -133,13 +141,24 @@ public void CalibrationRun_WithOverreport_RecordsReportedCapacityAboveActualCapa
ITapeCalibration? cal = calibrator.Run();
Assert.NotNull(cal);
- // TrueRemaining still drives hard EOM at the cartridge's real capacity, while the emulated
- // driver continues to over-report phantom free space in the tail.
+ // TrueRemaining still drives hard EOM at the cartridge's real capacity.
Assert.InRange(cal!.CapacityActual, (long)(Capacity * 0.98), Capacity);
- Assert.True(cal.CapacityReported > cal.CapacityActual,
- "Calibration should preserve the driver's optimistic reported-capacity side");
- Assert.True(cal.Curve[0].ReportedRemaining > 0,
- "Hard EOM should still leave a positive driver-reported remaining value when overreport is enabled");
+
+ // (a) BOM anchor — the driver's claim on the virgin cartridge, inflated by the boost only.
+ long expectedBom = Capacity + (long)(Capacity * reportedBoostPercent / 100.0);
+ Assert.InRange(cal.ReportedCapacityAtBom, (long)(expectedBom * 0.98), (long)(expectedBom * 1.02));
+
+ // (b) EOM anchor — the phantom free space still claimed at hard EOM, driven by the phantom knob only.
+ long expectedPhantom = (long)(Capacity * phantomFreePercent / 100.0);
+ Assert.InRange(cal.PhantomFreeAtEom,
+ (long)(expectedPhantom * 0.98), (long)(expectedPhantom * 1.02) + 1L);
+
+ // The two anchors are independent: neither knob may leak into the other's measurement.
+ if (reportedBoostPercent == 0.0)
+ Assert.InRange(cal.ReportedCapacityAtBom, (long)(Capacity * 0.98), (long)(Capacity * 1.02));
+ if (phantomFreePercent == 0.0)
+ Assert.InRange(cal.PhantomFreeAtEom, 0L, (long)(Capacity * 0.01));
+
Assert.Equal(0L, cal.Curve[0].ActualRemaining);
}
@@ -171,7 +190,8 @@ public void CalibrationJson_RoundTrips_AndRejectsUnknownFormat()
Assert.NotNull(loaded);
Assert.Equal(cal.FormatId, loaded!.FormatId);
Assert.Equal(cal.ProfileKey, loaded.ProfileKey);
- Assert.Equal(cal.CapacityReported, loaded.CapacityReported);
+ Assert.Equal(cal.ReportedCapacityAtBom, loaded.ReportedCapacityAtBom);
+ Assert.Equal(cal.PhantomFreeAtEom, loaded.PhantomFreeAtEom);
Assert.Equal(cal.CapacityActual, loaded.CapacityActual);
Assert.Equal(cal.EwToEomDistance, loaded.EwToEomDistance);
Assert.Equal(cal.Curve.Count, loaded.Curve.Count);
@@ -181,7 +201,7 @@ public void CalibrationJson_RoundTrips_AndRejectsUnknownFormat()
// A blob with an unrecognized FormatId must be rejected.
using var bad = new MemoryStream();
using (var writer = new StreamWriter(bad, leaveOpen: true))
- writer.Write("""{"FormatId":"unknown/9","ProfileKey":"x","CapacityReported":1,"CapacityActual":1,"Curve":[],"EarlyWarning":null}""");
+ writer.Write("""{"FormatId":"unknown/9","ProfileKey":"x","ReportedCapacityAtBom":1,"PhantomFreeAtEom":0,"CapacityActual":1,"Curve":[],"EarlyWarning":null}""");
bad.Position = 0;
Assert.Null(TapeCalibration.LoadFrom(bad));
}
@@ -317,7 +337,7 @@ public void LogicalEw_AfterPhysicalEw_FiresFromByteCountWithSmallReserve()
break;
// Track whether the physical EW was observed before logical EW fired.
- physicalSeen |= drive.EstimateActualRemaining() < drive.GetRemainingCapacity();
+ physicalSeen |= drive.EstimateActualRemaining() < drive.GetReportedContentRemaining();
if (ew)
{
diff --git a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
index 1ec1f5a..18e9f83 100644
--- a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
+++ b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
@@ -51,7 +51,8 @@ public async Task ExecuteCalibrateAsync_ReturnsCalibrationAndLogsSummary()
Assert.False(result.WasAborted);
Assert.NotNull(result.Calibration);
Assert.Equal(service.DriveProfileKey, result.ProfileKey);
- Assert.True(result.CapacityReported > result.CapacityActual);
+ Assert.True(result.ReportedCapacityTotal > result.CapacityActual);
+ Assert.True(result.PhantomFreeAtEom > 0);
Assert.True(result.CapacityActual > 0);
Assert.True(result.EwToEomDistance > 0);
Assert.Contains(ServiceStateChange.OperationStarted, host.StateChanges);
@@ -96,10 +97,12 @@ public async Task ExecuteCalibrateAsync_HonorsAbortRequest()
}
[Fact]
- public async Task ExecuteCalibrateAsync_WithCustomOverreport_ExposesReportedCapacityGap()
+ public async Task ExecuteCalibrateAsync_WithCustomOverreport_ExposesBothOverreportAnchors()
{
var (service, _) = await OpenCalibrationServiceAsync(
- ewProfile: VirtualTapeEwProfile.Lto4Like(CalibrationCapacity, ewZonePercent: 4.0, floorPercent: 10.0));
+ ewProfile: VirtualTapeEwProfile.Lto4Like(
+ CalibrationCapacity, ewZonePercent: 4.0,
+ phantomFreePercent: 10.0, reportedBoostPercent: 5.0));
using (service)
{
@@ -114,7 +117,9 @@ public async Task ExecuteCalibrateAsync_WithCustomOverreport_ExposesReportedCapa
}));
Assert.True(result.Success);
- Assert.True(result.CapacityReported > result.CapacityActual);
+ // (a) capacity inflated at BOM, and (b) phantom free space still claimed at hard EOM.
+ Assert.True(result.ReportedCapacityAtBom > result.CapacityActual);
+ Assert.True(result.PhantomFreeAtEom > 0);
Assert.NotNull(result.Calibration);
Assert.True(result.Calibration!.Curve[0].ReportedRemaining > 0);
Assert.Equal(0L, result.Calibration.Curve[0].ActualRemaining);
diff --git a/TapeLibNET.Tests/VirtualDriveBasicTests.cs b/TapeLibNET.Tests/VirtualDriveBasicTests.cs
index 83eec32..be9e7d1 100644
--- a/TapeLibNET.Tests/VirtualDriveBasicTests.cs
+++ b/TapeLibNET.Tests/VirtualDriveBasicTests.cs
@@ -529,13 +529,13 @@ public void Remaining_DecreasesAfterWrite(DriveProfile profile)
var drive = fixture.Drive;
int blockSize = (int)drive.BlockSize;
- long remainingBefore = drive.GetRemainingCapacity();
+ long remainingBefore = drive.GetReportedContentRemaining();
// Write some data
byte[] data = new byte[blockSize * 10];
drive.WriteDirect(data, 0, data.Length);
- long remainingAfter = drive.GetRemainingCapacity();
+ long remainingAfter = drive.GetReportedContentRemaining();
Assert.True(remainingAfter < remainingBefore,
$"Remaining should decrease after write: {remainingBefore} → {remainingAfter}");
}
diff --git a/TapeLibNET/Services/ServiceOperationProgressHandler.cs b/TapeLibNET/Services/ServiceOperationProgressHandler.cs
index db9b11e..3b5f589 100644
--- a/TapeLibNET/Services/ServiceOperationProgressHandler.cs
+++ b/TapeLibNET/Services/ServiceOperationProgressHandler.cs
@@ -444,7 +444,8 @@ public CalibrateResult GenerateResult(
Error = error,
Calibration = calibration,
ProfileKey = calibration?.ProfileKey ?? string.Empty,
- CapacityReported = calibration?.CapacityReported ?? BytesTotal,
+ ReportedCapacityAtBom = calibration?.ReportedCapacityAtBom ?? BytesTotal,
+ PhantomFreeAtEom = calibration?.PhantomFreeAtEom ?? 0,
CapacityActual = calibration?.CapacityActual ?? BytesProcessed,
EarlyWarning = calibration?.EarlyWarning,
EwToEomDistance = calibration?.EwToEomDistance ?? 0L,
diff --git a/TapeLibNET/Services/ServiceOperationResult.cs b/TapeLibNET/Services/ServiceOperationResult.cs
index e38a37b..6c10fd8 100644
--- a/TapeLibNET/Services/ServiceOperationResult.cs
+++ b/TapeLibNET/Services/ServiceOperationResult.cs
@@ -129,13 +129,25 @@ public sealed record CalibrateResult : FileOperationResult
public string ProfileKey { get; init; } = string.Empty;
///
- /// Effective driver-reported capacity (bytes): the largest total capacity implied by the driver's
- /// reported remaining values during calibration, including any phantom free space it still claims
- /// at hard EOM.
+ /// Quantity (4) — the driver's remaining claim on a virgin cartridge, sampled at BOM at the start of
+ /// the run. Compare against to see whether the driver inflates capacity
+ /// from the first byte.
///
- public long CapacityReported { get; init; }
+ public long ReportedCapacityAtBom { get; init; }
- /// True raw capacity measured at hard EOM (bytes).
+ ///
+ /// Quantity (5) — the headline number of a calibration run: the phantom free space the driver still
+ /// claimed at the instant hard EOM fired. This space does not exist. LTO-4: ~28 GB.
+ ///
+ public long PhantomFreeAtEom { get; init; }
+
+ ///
+ /// The total capacity implied by the driver's own figures: plus the
+ /// it still claims at hard EOM.
+ ///
+ public long ReportedCapacityTotal => CapacityActual + PhantomFreeAtEom;
+
+ /// True raw capacity measured at hard EOM (bytes) — quantity (1).
public long CapacityActual { get; init; }
/// Captured EW landmark, or when none was observed.
diff --git a/TapeLibNET/Services/TapeServiceBase.Backup.cs b/TapeLibNET/Services/TapeServiceBase.Backup.cs
index 7933808..f283b2f 100644
--- a/TapeLibNET/Services/TapeServiceBase.Backup.cs
+++ b/TapeLibNET/Services/TapeServiceBase.Backup.cs
@@ -452,7 +452,7 @@ private BackupResult ExecuteBackupCore(BackupRequest request)
if (tocSecs >= 1.0) timingParts.Add($"TOC save {FormatElapsed(tocSecs)}");
LogInfoSub(string.Join(", ", timingParts));
}
- LogInfoSub($"Remaining media capacity: {Helpers.BytesToStringLong(_drive.GetRemainingContentCapacity())}");
+ LogInfoSub($"Remaining media capacity (reported): {Helpers.BytesToStringLong(_drive.GetReportedContentRemaining())}");
// If backup was aborted, TOC has been saved — break out
if (wasAborted)
diff --git a/TapeLibNET/Services/TapeServiceBase.EW.cs b/TapeLibNET/Services/TapeServiceBase.EW.cs
index d70d7ce..f4d2734 100644
--- a/TapeLibNET/Services/TapeServiceBase.EW.cs
+++ b/TapeLibNET/Services/TapeServiceBase.EW.cs
@@ -62,7 +62,8 @@ CalibrateResult MakeResult(
{
Calibration = calibration,
ProfileKey = calibration?.ProfileKey ?? _drive?.DriveProfileKey ?? string.Empty,
- CapacityReported = calibration?.CapacityReported ?? _drive?.Capacity ?? 0,
+ ReportedCapacityAtBom = calibration?.ReportedCapacityAtBom ?? _drive?.Capacity ?? 0,
+ PhantomFreeAtEom = calibration?.PhantomFreeAtEom ?? 0,
CapacityActual = calibration?.CapacityActual ?? 0,
EarlyWarning = calibration?.EarlyWarning,
EwToEomDistance = calibration?.EwToEomDistance ?? 0,
@@ -199,4 +200,56 @@ public bool AddCalibration(ITapeCalibration calibration)
return false;
return _drive.AddCalibration(calibration);
}
+
+ // ── Calibration autoload ──────────────────────────────────────────────────
+
+ private TapeCalibrationStore? _calibrationStore;
+
+ ///
+ /// The shared, library-scoped calibration store (%LocalAppData%\TapeLibNET\Calibrations),
+ /// created on first use. Every TapeLibNET consumer sees the same profiles.
+ ///
+ public TapeCalibrationStore CalibrationStore => _calibrationStore ??= new(_loggerFactory);
+
+ ///
+ /// Feeds every stored calibration profile to the drive, so the one matching this drive+media
+ /// activates itself without any user action — a measured profile is worthless if the user has to
+ /// remember to apply it. The drive matches on and silently
+ /// keeps the non-matching ones for when other media is loaded.
+ ///
+ /// Non-throwing and non-fatal: a store that cannot be read simply leaves the drive uncalibrated,
+ /// falling back to the a-priori estimate. Call after the drive is open AND media is loaded, since
+ /// the profile key includes the media capacity bucket.
+ ///
+ ///
+ /// The number of profiles offered, or 0 when none were available.
+ protected int AutoLoadCalibrations()
+ {
+ if (_drive is null)
+ return 0;
+
+ try
+ {
+ var calibrations = CalibrationStore.LoadAll();
+ if (calibrations.Count == 0)
+ return 0;
+
+ foreach (var cal in calibrations)
+ _drive.AddCalibration(cal);
+
+ if (_drive.Calibration is { } matched)
+ LogInfoSub($"Calibration applied: {matched.ProfileKey}");
+ else
+ LogInfoSub($"Calibration: {calibrations.Count} profile(s) loaded, none matching " +
+ $"'{_drive.DriveProfileKey}' — using the a-priori estimate");
+
+ return calibrations.Count;
+ }
+ catch (Exception ex)
+ {
+ // Never let a calibration-store problem break opening a drive or loading media.
+ LogInfoSub($"Calibration profiles unavailable: {ex.Message}");
+ return 0;
+ }
+ }
}
diff --git a/TapeLibNET/Services/TapeServiceBase.List.cs b/TapeLibNET/Services/TapeServiceBase.List.cs
index 9181aff..2fda80b 100644
--- a/TapeLibNET/Services/TapeServiceBase.List.cs
+++ b/TapeLibNET/Services/TapeServiceBase.List.cs
@@ -252,7 +252,10 @@ protected virtual void LogDriveInfo()
{
LogInfoSub($"Partition count: {PartitionCount}");
LogInfoSub($"Capacity: {Helpers.BytesToStringLong(Capacity)}");
- LogInfoSub($"Remaining (est. from drive): {Helpers.BytesToStringLong(_drive.GetRemainingContentCapacity())}");
+ LogInfoSub($"Remaining (reported): {Helpers.BytesToStringLong(ReportedContentRemaining)}");
+ LogInfoSub($"Remaining (estimated): {Helpers.BytesToStringLong(EstimatedContentRemaining)}");
+ LogInfoSub($"Writable: {Helpers.BytesToStringLong(WritableRemaining)}");
+ LogInfoSub($"Estimation by: {RemainingEstimationSource}");
}
}
@@ -301,8 +304,10 @@ protected virtual void LogMediaInfoFull()
LogInfoSub($"Backup sets: {toc.Count}");
LogInfoSub($"Capacity: {Helpers.BytesToStringLong(Capacity)}");
LogInfoSub($"Used: {Helpers.BytesToStringLong(Used)}");
- LogInfoSub($"Remaining: {Helpers.BytesToStringLong(Remaining)}");
- LogInfoSub($"Remaining (est. from drive): {Helpers.BytesToStringLong(_drive.GetRemainingContentCapacity())}");
+ LogInfoSub($"Remaining (reported): {Helpers.BytesToStringLong(ReportedContentRemaining)}");
+ LogInfoSub($"Remaining (estimated): {Helpers.BytesToStringLong(EstimatedContentRemaining)}");
+ LogInfoSub($"Writable: {Helpers.BytesToStringLong(WritableRemaining)}");
+ LogInfoSub($"Estimation by: {RemainingEstimationSource}");
LogInfoSub($"TOC placement: {(HasInitiatorPartition ? "partition" : "set")}");
LogInfoSub($"Volume: #{toc.Volume}");
LogInfoSub($"Continued on next volume: {(toc.ContinuedOnNextVolume ? "Yes" : "No")}");
diff --git a/TapeLibNET/Services/TapeServiceBase.cs b/TapeLibNET/Services/TapeServiceBase.cs
index b5773a5..1ace4a3 100644
--- a/TapeLibNET/Services/TapeServiceBase.cs
+++ b/TapeLibNET/Services/TapeServiceBase.cs
@@ -168,49 +168,158 @@ public long Used
}
///
- /// Estimated remaining capacity in bytes — the authoritative calibrated estimate from the drive,
- /// with room reserved for the TOC when it is co-located with content (no Initiator partition).
+ /// Quantity (3) — the RAW remaining capacity as reported by the drive/backend, for diagnostics and
+ /// "driver says vs. we estimate" display. Optimistic; never spend it.
///
- public long Remaining => _drive is not null
- ? Math.Max(0L, _drive.Remaining - (HasInitiatorPartition ? 0L : DefaultTOCCapacity))
- : 0;
+ public long ReportedContentRemaining => _drive?.ReportedContentRemaining ?? 0;
///
- /// The raw remaining capacity as reported by the drive/backend, for diagnostics and
- /// "driver says vs. we estimate" display. Prefer for capacity decisions.
+ /// Quantity (6) — the authoritative calibrated ESTIMATE of the bytes physically still writable on the
+ /// content partition. Includes the space the TOC will need; see .
///
- public long DriverReportedRemaining => _drive?.DriverReportedRemaining ?? 0;
+ public long EstimatedContentRemaining => _drive?.EstimatedContentRemaining ?? 0;
+
+ ///
+ /// Quantity (9) — the number the user cares about: the bytes a backup may actually spend on
+ /// content, i.e. less the TOC reserve when the TOC shares the
+ /// content partition (no Initiator partition).
+ ///
+ public long WritableRemaining => ComputeWritableRemaining(EstimatedContentRemaining);
+
+ ///
+ /// Quantity (4') — the best available estimate of the content partition's TRUE total capacity:
+ /// the calibration's measured when one is in force,
+ /// otherwise the driver-reported . Use this as the denominator in
+ /// "writable X of Y" displays so the ratio is expressed on a single, consistent axis.
+ ///
+ public long EstimatedCapacity
+ {
+ get
+ {
+ long actual = _drive?.Calibration?.CapacityActual ?? 0;
+ return actual > 0 ? actual : Capacity;
+ }
+ }
+
+ ///
+ /// The visible manifestation of over-reporting: quantity (3) − quantity (6). Zero when no calibration
+ /// is in force (the estimate then simply passes the raw figure through).
+ ///
+ public long RemainingOverreport => Math.Max(0L, ReportedContentRemaining - EstimatedContentRemaining);
/// How the remaining-capacity estimate / logical early warning is currently realized.
- public EarlyWarningMechanism EstimateMechanism => _drive?.EarlyWarningMechanism ?? EarlyWarningMechanism.None;
+ public EarlyWarningMechanism RemainingEstimateMechanism => _drive?.EarlyWarningMechanism ?? EarlyWarningMechanism.None;
/// True once the drive has sensed a logical early-warning crossing this session.
public bool IsEarlyWarning => _drive?.IsEarlyWarning ?? false;
+ /// Maximum number of characters of a calibration profile key shown in the status text.
+ private const int c_maxProfileKeyDisplay = 48;
+
///
- /// Adjusts the remaining content capacity accounting for the drive reporting and TOC capacity.
- /// Do not deduct the TOC capacity; the method will do this.
- /// Delegates to .
+ /// One-line, user-facing summary for the status bar: the WRITABLE space (quantity (9)) against the
+ /// ESTIMATED total capacity (quantity (4')), together with the end-of-tape policy actually in force.
+ ///
+ /// Both figures are quoted on the ESTIMATED axis so the ratio is internally consistent — never mix a
+ /// writable (corrected) numerator with a driver-reported (optimistic) denominator.
+ ///
+ ///
+ /// With the TOC in its own partition the content partition may be filled right up to the
+ /// physical end of medium; with the TOC co-located with content we must wrap up at the
+ /// logical early warning, whose quality depends on .
+ ///
+ /// Examples: "Writable 597 GB of 780 GB, fill to EW - HW reported",
+ /// "Writable 1.2 TB of 1.5 TB, fill to EOM", "… ⚠ EW reached - calibrated by …".
///
- ///
- /// The remaining content capacity to adjust without deducted TOC capacity.
- ///
- /// The adjusted remaining content capacity.
- [Obsolete("Phase 3: superseded by Remaining (calibrated estimate). Retained as a backstop.")]
- public long AdjustRemainingContentCapacity(long remainingCapacity) =>
- _drive is not null
- ? TapeNavigator.AdjustRemainingContentCapacity(_drive, remainingCapacity)
- : 0;
+ public string RemainingAndEwStatus
+ {
+ get
+ {
+ if (!IsDriveOpen || !IsMediaLoaded)
+ return string.Empty;
+
+ string writable = $"Writable {Helpers.BytesToString(WritableRemaining)}" +
+ $" of {Helpers.BytesToString(EstimatedCapacity)}";
+
+ // TOC in its own partition: content may run to the hard end of medium.
+ if (HasInitiatorPartition)
+ return $"{writable}, fill to EOM";
+
+ // TOC co-located with content: we must stop at the logical early warning.
+ string policy = IsEarlyWarning ? "\u26a0 EW reached" : "fill to EW";
+ return $"{writable}, {policy}{EarlyWarningMechanismText}";
+ }
+ }
+
+ ///
+ /// The "Estimation by" figure for property panes: names the source of the remaining-capacity estimate
+ /// and flags an early-warning crossing. Examples: "none", "apriori", "Hardware",
+ /// "Calibration …|cap=780GB", "Calibration … (⚠ EW reached)".
+ ///
+ public string RemainingEstimationSource
+ {
+ get
+ {
+ string source = RemainingEstimateMechanism switch
+ {
+ EarlyWarningMechanism.Uncalibrated => "apriori",
+ EarlyWarningMechanism.Calibrated =>
+ $"Calibration {TruncateProfileKey(_drive?.Calibration?.ProfileKey)}",
+ EarlyWarningMechanism.HardwareEarlyWarning => "Hardware",
+ EarlyWarningMechanism.ProgrammableEarlyWarning => "Hardware (programmed)",
+ _ => "none",
+ };
+
+ return IsEarlyWarning ? $"{source} (\u26a0 EW reached)" : source;
+ }
+ }
+
+ ///
+ /// Suffix describing how the logical early warning is realized, e.g. " - HW reported".
+ /// Empty when no early-warning reserve is in force.
+ ///
+ protected string EarlyWarningMechanismText => RemainingEstimateMechanism switch
+ {
+ EarlyWarningMechanism.Uncalibrated => " - apriori",
+ EarlyWarningMechanism.Calibrated => $" - calibrated by {TruncateProfileKey(_drive?.Calibration?.ProfileKey)}",
+ EarlyWarningMechanism.HardwareEarlyWarning => " - HW reported",
+ EarlyWarningMechanism.ProgrammableEarlyWarning => " - HW programmed",
+ _ => string.Empty, // None
+ };
+
+ /// Shortens a calibration profile key for display, keeping its tail (capacity bucket).
+ private static string TruncateProfileKey(string? profileKey)
+ {
+ if (string.IsNullOrEmpty(profileKey))
+ return "(unnamed profile)";
+ return profileKey.Length <= c_maxProfileKeyDisplay
+ ? profileKey
+ : "\u2026" + profileKey[^(c_maxProfileKeyDisplay - 1)..];
+ }
+
+ ///
+ /// Converts a hypothetical ESTIMATED free space (quantity (6)) into WRITABLE-for-content space
+ /// (quantity (9)) by deducting the TOC reserve when the TOC shares the content partition.
+ ///
+ /// Pure and side-effect free, so callers may pass a what-if figure — e.g. the free space that
+ /// would exist if certain backup sets were added or removed — rather than the drive's current one.
+ /// Pass the free space without the TOC deducted; this method performs the deduction.
+ ///
+ ///
+ /// Hypothetical estimated free bytes, TOC reserve NOT yet deducted.
+ /// The writable-for-content bytes, never negative.
+ public long ComputeWritableRemaining(long estimatedFree)
+ => Math.Max(0L, estimatedFree - (HasInitiatorPartition ? 0L : DefaultTOCCapacity));
///
- /// Reads the remaining capacity directly from the drive hardware (thread-safe).
- /// Do NOT call this method while another operation that obtains the lock!
+ /// Reads the RAW driver-reported remaining capacity directly from the drive hardware (thread-safe).
+ /// Do NOT call this method while another operation holds the lock!
///
- public long GetRemainingCapacityFromDrive()
+ public long GetReportedRemainingFromDrive()
{
// Brief lock — just reading a hardware register, never blocks long.
_operationLock.Wait();
- try { return _drive?.GetRemainingContentCapacity() ?? 0; }
+ try { return _drive?.GetReportedContentRemaining() ?? 0; }
finally { _operationLock.Release(); }
}
@@ -270,6 +379,7 @@ public Task OpenDriveAsync(int driveNumber)
DriveNumber = driveNumber;
LogOk($"Drive {driveNumber} opened successfully");
LogInfoSub($"Device name: {_drive.DriveDeviceName}");
+ AutoLoadCalibrations();
_host.OnServiceStateChanged(ServiceStateChange.DriveOpened);
return true;
}
@@ -324,6 +434,8 @@ public Task LoadMediaAsync()
LogOk("Media loaded successfully");
LogMediaInfo();
+ // The profile key depends on the medium's capacity bucket, so re-match on every load.
+ AutoLoadCalibrations();
_host.OnServiceStateChanged(ServiceStateChange.MediaLoaded);
return true;
}
@@ -753,7 +865,7 @@ protected virtual void LogMediaInfo()
if (_drive is null) return;
LogInfoSub($"Partition count: {_drive.PartitionCount}");
LogInfoSub($"Capacity: {Helpers.BytesToStringLong(_drive.ContentCapacity)}");
- LogInfoSub($"Remaining (est.): {Helpers.BytesToStringLong(_drive.GetRemainingContentCapacity())}");
+ LogInfoSub($"Remaining (reported): {Helpers.BytesToStringLong(_drive.GetReportedContentRemaining())}");
}
///
diff --git a/TapeLibNET/TapeBackupAgent.cs b/TapeLibNET/TapeBackupAgent.cs
index f88c4f7..12fd200 100644
--- a/TapeLibNET/TapeBackupAgent.cs
+++ b/TapeLibNET/TapeBackupAgent.cs
@@ -77,12 +77,12 @@ protected override void Dispose(bool disposing)
private long ComputeRemainingCapacity()
{
- // Phase 3: the authoritative remaining-capacity figure is the drive's calibrated estimate
- // (Drive.Remaining), from which we reserve room for the TOC when it is co-located with
- // content (no Initiator partition). The old Navigator.AdjustRemainingContentCapacity
- // heuristic is retired here; early-warning enforcement (see BeginWriteContentForCurrentSet)
- // is the real stop signal, this value is only a backstop for the legacy capacity checks.
- var remainingCapacity = Drive.Remaining
+ // The authoritative remaining-capacity figure is the drive's calibrated ESTIMATE
+ // (quantity (6)), from which we reserve room for the TOC when it is co-located with content
+ // (no Initiator partition) — yielding the WRITABLE remaining, quantity (9). Early-warning
+ // enforcement (see BeginWriteContentForCurrentSet) is the real stop signal; this value is
+ // only a backstop for the capacity pre-checks.
+ var remainingCapacity = Drive.EstimatedContentRemaining
- (Drive.HasInitiatorPartition ? 0L : Navigator.TOCCapacity);
return Math.Max(remainingCapacity, 0L);
}
diff --git a/TapeLibNET/TapeCalibration.cs b/TapeLibNET/TapeCalibration.cs
index 2eee477..5394cd5 100644
--- a/TapeLibNET/TapeCalibration.cs
+++ b/TapeLibNET/TapeCalibration.cs
@@ -34,15 +34,28 @@ public interface ITapeCalibration
string ProfileKey { get; }
///
- /// Effective driver-reported capacity (bytes): the largest total capacity implied by the driver's
- /// reported remaining values during calibration, including any phantom free space it still claims
- /// at hard EOM.
+ /// Quantity (4) — the driver-reported remaining sampled at BOM (beginning of media), i.e. the
+ /// drive's own idea of the cartridge size. May exceed when the drive
+ /// inflates its capacity from the very first byte; observed ≈ equal on LTO-4.
///
- long CapacityReported { get; }
+ long ReportedCapacityAtBom { get; }
+
+ ///
+ /// Quantity (5) — the driver-reported remaining still claimed at the instant hard EOM fires:
+ /// phantom free space that does not physically exist (LTO-4: ~28 GB). The headline measure of how
+ /// much the drive over-reports.
+ ///
+ long PhantomFreeAtEom { get; }
/// True raw capacity measured as bytes written at hard EOM (bytes) — the ground truth.
long CapacityActual { get; }
+ ///
+ /// The total capacity implied by the driver's reporting: everything it ever claimed was writable,
+ /// including the phantom tail. Derived: + .
+ ///
+ long ReportedCapacityTotal => CapacityActual + PhantomFreeAtEom;
+
/// The calibrated curve, sorted ascending by .
IReadOnlyList Curve { get; }
@@ -88,7 +101,7 @@ public sealed class TapeCalibration : ITapeCalibration
#region *** Constants ***
/// Current on-disk format identifier.
- public const string CurrentFormatId = "tapelibnet-cal/1";
+ public const string CurrentFormatId = "tapelibnet-cal/2";
private const long c_bytesPerGB = 1024L * 1024 * 1024;
@@ -98,7 +111,8 @@ public sealed class TapeCalibration : ITapeCalibration
public string FormatId { get; }
public string ProfileKey { get; }
- public long CapacityReported { get; }
+ public long ReportedCapacityAtBom { get; }
+ public long PhantomFreeAtEom { get; }
public long CapacityActual { get; }
public IReadOnlyList Curve { get; }
public CalibrationPoint? EarlyWarning { get; }
@@ -108,12 +122,13 @@ public sealed class TapeCalibration : ITapeCalibration
#region *** Construction ***
private TapeCalibration(
- string formatId, string profileKey, long capacityReported, long capacityActual,
- IReadOnlyList curve, CalibrationPoint? earlyWarning)
+ string formatId, string profileKey, long reportedCapacityAtBom, long phantomFreeAtEom,
+ long capacityActual, IReadOnlyList curve, CalibrationPoint? earlyWarning)
{
FormatId = formatId;
ProfileKey = profileKey;
- CapacityReported = capacityReported;
+ ReportedCapacityAtBom = reportedCapacityAtBom;
+ PhantomFreeAtEom = phantomFreeAtEom;
CapacityActual = capacityActual;
Curve = curve;
EarlyWarning = earlyWarning;
@@ -125,21 +140,31 @@ private TapeCalibration(
/// curve using (bytes at hard EOM): ActualRemaining = CapacityActual − ActualWritten.
///
/// Usually so a fresh run always matches.
- /// Driver-reported remaining at BOT.
- /// Bytes written at hard EOM (ground truth).
+ /// Driver-reported remaining at BOM — quantity (4).
+ /// Bytes written at hard EOM (ground truth) — quantity (1).
/// The (ActualWritten, ReportedRemaining) pairs, including the EOM point.
/// The (ActualWritten, ReportedRemaining) at first EW, or null if none.
public static TapeCalibration FromMeasurements(
- string profileKey, long capacityReportedAtBot, long capacityActual,
+ string profileKey, long reportedCapacityAtBom, long capacityActual,
IEnumerable<(long ActualWritten, long ReportedRemaining)> rawSamples,
(long ActualWritten, long ReportedRemaining)? earlyWarning)
{
var pts = new List();
- long capacityReported = Math.Max(0L, capacityReportedAtBot);
+
+ // The phantom free space at EOM is the reported figure at the DEEPEST sample — the last thing
+ // the driver claimed while the medium was already physically full. It is an independent
+ // measurement, not derivable from the BOM anchor.
+ long deepestWritten = -1L;
+ long phantomFreeAtEom = 0L;
+
foreach (var (aw, rr) in rawSamples)
{
pts.Add(new CalibrationPoint(rr, Math.Max(0L, capacityActual - aw)));
- capacityReported = Math.Max(capacityReported, aw + rr);
+ if (aw > deepestWritten)
+ {
+ deepestWritten = aw;
+ phantomFreeAtEom = Math.Max(0L, rr);
+ }
}
// Sort ascending by ReportedRemaining; on ties keep the CONSERVATIVE (smallest) ActualRemaining.
@@ -158,7 +183,8 @@ public static TapeCalibration FromMeasurements(
? new CalibrationPoint(ew.ReportedRemaining, Math.Max(0L, capacityActual - ew.ActualWritten))
: null;
- return new TapeCalibration(CurrentFormatId, profileKey, capacityReported, capacityActual, curve, ewPoint);
+ return new TapeCalibration(CurrentFormatId, profileKey, Math.Max(0L, reportedCapacityAtBom),
+ phantomFreeAtEom, capacityActual, curve, ewPoint);
}
///
@@ -180,7 +206,7 @@ public static ITapeCalibration Apriori(
//
// ActualRemaining
// ^
- // 741┤ capacityActual ● BOT
+ // 741┤ capacityActual ● BOM
// (GB)│ = capacity - margin ╱ (reported=780, actual=741)
// │ ╱
// │ ╱
@@ -207,7 +233,7 @@ public static ITapeCalibration Apriori(
// Curve (ascending by ReportedRemaining):
// at reported == margin → actual == 0 (blind stop point)
- // at reported == capacity → actual == capacity − margin (BOT)
+ // at reported == capacity → actual == capacity − margin (BOM)
var curve = new List
{
new(margin, 0L),
@@ -216,7 +242,9 @@ public static ITapeCalibration Apriori(
CalibrationPoint? ew = new CalibrationPoint(ewReported, Math.Max(0L, ewReported - margin));
- return new TapeCalibration("tapelibnet-cal-apriori/1", profileKey, capacity, capacityActual, curve, ew);
+ // A-priori assumes NO capacity boost at BOM (quantity (4) == the nominal capacity) and treats the
+ // whole margin as phantom free space still claimed at hard EOM (quantity (5)).
+ return new TapeCalibration("tapelibnet-cal-apriori/2", profileKey, capacity, margin, capacityActual, curve, ew);
}
#endregion
@@ -240,7 +268,7 @@ public long TranslateRemaining(long reportedRemaining)
if (reportedRemaining <= c[0].ReportedRemaining)
return c[0].ActualRemaining; // clamp low (near EOM → conservative)
if (reportedRemaining >= c[^1].ReportedRemaining)
- return c[^1].ActualRemaining; // clamp high (near BOT)
+ return c[^1].ActualRemaining; // clamp high (near BOM)
// Binary-search the bracketing pair, then linearly interpolate.
int lo = 0, hi = c.Count - 1;
@@ -273,7 +301,7 @@ public long TranslateActualToReported(long actualRemaining)
if (actualRemaining <= c[0].ActualRemaining)
return c[0].ReportedRemaining; // clamp low (near EOM)
if (actualRemaining >= c[^1].ActualRemaining)
- return c[^1].ReportedRemaining; // clamp high (near BOT)
+ return c[^1].ReportedRemaining; // clamp high (near BOM)
// Binary-search the bracketing pair on the ActualRemaining axis, then linearly interpolate.
int lo = 0, hi = c.Count - 1;
@@ -302,23 +330,28 @@ public long TranslateActualToReported(long actualRemaining)
/// exact string equality against .
///
public static string MakeProfileKey(string vendor, string product, string revision, long capacityBytes)
- => $"{vendor}|{product}|{revision}|cap={CapacityBucketGB(capacityBytes)}GB";
+ => $"{vendor}|{product}|{revision}|cap={CapacityBucket(capacityBytes)}";
///
- /// Coarse GB bucket (2 significant figures) matching the backend's bucketing, so a key made here
- /// lines up with the backend-generated one. Absorbs cartridge-to-cartridge jitter while keeping
- /// distinct media generations apart.
+ /// Coarse capacity bucket (2 significant figures) shared with the backend, so a key made here lines
+ /// up with the backend-generated one. Absorbs cartridge-to-cartridge jitter while keeping distinct
+ /// media generations apart. Media below 2 GB are bucketed in MB (500MB) rather than collapsing
+ /// to 0GB, so small virtual test cartridges of different sizes stay distinguishable.
///
- public static long CapacityBucketGB(long capacityBytes)
+ public static string CapacityBucket(long capacityBytes)
{
if (capacityBytes <= 0)
- return 0;
+ return "0";
+
+ const long bytesPerMB = 1024L * 1024;
+ bool useMB = capacityBytes < 2L * c_bytesPerGB;
+ double value = capacityBytes / (double)(useMB ? bytesPerMB : c_bytesPerGB);
- double gb = capacityBytes / (double)c_bytesPerGB;
- double mag = Math.Pow(10, Math.Floor(Math.Log10(gb)) - 1);
+ // Keep 2 significant figures: round to the nearest 10^(floor(log10)-1), never below 1 unit.
+ double mag = Math.Pow(10, Math.Floor(Math.Log10(value)) - 1);
if (mag < 1) mag = 1;
- return (long)(Math.Round(gb / mag) * mag);
+ return $"{(long)(Math.Round(value / mag) * mag)}{(useMB ? "MB" : "GB")}";
}
#endregion
@@ -329,7 +362,8 @@ public static long CapacityBucketGB(long capacityBytes)
private sealed record Dto(
string FormatId,
string ProfileKey,
- long CapacityReported,
+ long ReportedCapacityAtBom,
+ long PhantomFreeAtEom,
long CapacityActual,
List Curve,
CalibrationPoint? EarlyWarning);
@@ -342,7 +376,7 @@ private sealed record Dto(
public void SaveTo(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
- var dto = new Dto(FormatId, ProfileKey, CapacityReported, CapacityActual,
+ var dto = new Dto(FormatId, ProfileKey, ReportedCapacityAtBom, PhantomFreeAtEom, CapacityActual,
[.. Curve], EarlyWarning);
JsonSerializer.Serialize(stream, dto, s_json);
}
@@ -362,12 +396,12 @@ public void SaveTo(Stream stream)
return null;
// Accept known format ids (run + apriori). Reject anything else.
- if (dto.FormatId != CurrentFormatId && dto.FormatId != "tapelibnet-cal-apriori/1")
+ if (dto.FormatId != CurrentFormatId && dto.FormatId != "tapelibnet-cal-apriori/2")
return null;
var curve = dto.Curve ?? [];
return new TapeCalibration(dto.FormatId, dto.ProfileKey,
- dto.CapacityReported, dto.CapacityActual, curve, dto.EarlyWarning);
+ dto.ReportedCapacityAtBom, dto.PhantomFreeAtEom, dto.CapacityActual, curve, dto.EarlyWarning);
}
catch (JsonException)
{
diff --git a/TapeLibNET/TapeCalibrator.cs b/TapeLibNET/TapeCalibrator.cs
index 6f0b066..912b682 100644
--- a/TapeLibNET/TapeCalibrator.cs
+++ b/TapeLibNET/TapeCalibrator.cs
@@ -135,7 +135,7 @@ public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder 0
- ? Math.Max(capacityReportedAtBot / Math.Max(1, Options.SampleCount), Options.MinSampleInterval)
+ long sampleInterval = capacityReportedAtBom > 0
+ ? Math.Max(capacityReportedAtBom / Math.Max(1, Options.SampleCount), Options.MinSampleInterval)
: Options.MinSampleInterval;
m_logger.LogInformation(
- "{Prefix}: Calibration start — profile '{Key}', reportedCapacity {Cap}, blockSize {Bs}, chunk {Chunk}, sampleInterval {Int}",
- LogPrefix, Drive.DriveProfileKey, capacityReportedAtBot, blockSize, chunkBytes, sampleInterval);
+ "{Prefix}: Calibration start — profile '{Key}', reportedCapacityAtBom {Cap}, blockSize {Bs}, chunk {Chunk}, sampleInterval {Int}",
+ LogPrefix, Drive.DriveProfileKey, capacityReportedAtBom, blockSize, chunkBytes, sampleInterval);
// --- Write to hard EOM, sampling as we go ---
var samples = new List<(long ActualWritten, long ReportedRemaining)>();
@@ -161,7 +161,7 @@ public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder= nextSample)
{
- long rr = Drive.GetRemainingCapacity();
+ long rr = Drive.GetReportedContentRemaining();
samples.Add((bytesWritten, rr));
progress?.Report(new TapeCalibrationProgress(
bytesWritten, rr, Drive.GetCurrentBlock(), EarlyWarning: ewPoint is not null, EndOfMedium: false, "sampling"));
@@ -233,13 +233,15 @@ public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder 0 ? 100.0 * capacityActual / calibration.CapacityReported : 0.0,
+ calibration.ReportedCapacityAtBom > 0 ? 100.0 * capacityActual / calibration.ReportedCapacityAtBom : 0.0,
+ calibration.PhantomFreeAtEom,
ewPoint is { } e ? $"{e.ActualWritten} bytes / RR {e.ReportedRemaining}" : "(none)",
samples.Count);
diff --git a/TapeLibNET/TapeDrive.cs b/TapeLibNET/TapeDrive.cs
index 729790e..544e5c1 100644
--- a/TapeLibNET/TapeDrive.cs
+++ b/TapeLibNET/TapeDrive.cs
@@ -211,19 +211,23 @@ public TimeSpan OperationTimeout
///
public long ContentCapacity => m_cachedContentCapacity >= 0 ? m_cachedContentCapacity : Capacity;
- /// Queries remaining capacity of the current partition (refreshes media params). Returns −1 on failure.
- public long GetRemainingCapacity() => EnsureMediaParams()?.Remaining ?? 0L;
+ ///
+ /// Quantity (3) — the RAW driver-reported remaining for the CURRENT partition (refreshes media
+ /// params). Optimistic on real hardware: it overshoots the truth and floors above zero at hard EOM.
+ /// Use for capacity decisions. Returns 0 on failure.
+ ///
+ public long GetReportedRemaining() => EnsureMediaParams()?.Remaining ?? 0L;
///
- /// Remaining capacity of the Content partition, cached from the last time media params
- /// were refreshed while on Content. If currently on Content, refreshes first.
+ /// Quantity (3) for the Content partition, cached from the last time media params were refreshed
+ /// while on Content. If currently on Content, refreshes first. Still the RAW driver figure.
///
- public long GetRemainingContentCapacity()
+ public long GetReportedContentRemaining()
{
if (m_onContentPartition)
{
// On content — refresh to get the latest value and cache it
- return GetRemainingCapacity();
+ return GetReportedRemaining();
}
// On another partition — return the cached content remaining
@@ -231,19 +235,19 @@ public long GetRemainingContentCapacity()
}
///
- /// The authoritative estimate of bytes still actually writable — the figure the rest of the
- /// library and the apps should consume for "remaining capacity". Calibrated when a calibration
- /// (measured or a-priori) is available, otherwise the raw driver value.
+ /// Quantity (6) — the authoritative ESTIMATE of bytes still actually writable on the Content
+ /// partition: the figure the rest of the library and the apps should consume. Calibrated when a
+ /// calibration (measured or a-priori) is available, otherwise the raw driver value.
/// Delegates to (throttled/cached per its contract).
///
- public long Remaining => EstimateActualRemaining();
+ public long EstimatedContentRemaining => EstimateActualRemaining();
///
- /// The raw remaining capacity as reported by the drive/backend, kept for diagnostics,
- /// calibration, and "driver says vs. we estimate" UI display. Prefer
- /// for capacity decisions.
+ /// Quantity (3) — property form of , kept for diagnostics,
+ /// calibration, and "driver says vs. we estimate" UI display. Prefer
+ /// for capacity decisions.
///
- public long DriverReportedRemaining => GetRemainingContentCapacity();
+ public long ReportedContentRemaining => GetReportedContentRemaining();
///
/// True if the underlying backend is a Win32 tape drive and the drive is an LTO model.
@@ -602,7 +606,7 @@ private bool EvaluateLogicalEarlyWarning(int written, bool physicalEw)
return physicalEw;
m_bytesSinceRemainingPoll = 0L;
- long est = cal.TranslateRemaining(GetRemainingCapacity());
+ long est = cal.TranslateRemaining(GetReportedRemaining());
return est <= m_desiredEarlyWarning || physicalEw;
}
@@ -701,7 +705,7 @@ private void SelectCalibration()
///
public long EstimateActualRemaining()
{
- long reported = GetRemainingCapacity();
+ long reported = GetReportedRemaining();
if (reported < 0L)
return 0L;
ITapeCalibration? cal = EffectiveCalibration;
diff --git a/TapeLibNET/TapeDriveBackend.cs b/TapeLibNET/TapeDriveBackend.cs
index 3335444..1bd932c 100644
--- a/TapeLibNET/TapeDriveBackend.cs
+++ b/TapeLibNET/TapeDriveBackend.cs
@@ -126,26 +126,17 @@ protected TapeDriveBackend(ILoggerFactory loggerFactory)
///
/// Backends may override to add density or other discriminators.
///
- public virtual string ProfileKey => $"{Vendor}|{Product}|{Revision}|cap={CapacityBucketGB(Capacity)}GB";
+ public virtual string ProfileKey => $"{Vendor}|{Product}|{Revision}|cap={TapeCalibration.CapacityBucket(Capacity)}";
///
- /// Rounds a native capacity (bytes) to a coarse GB bucket (2 significant figures) so that
+ /// Rounds a native capacity to a coarse bucket string (2 significant figures) so that
/// cartridge-to-cartridge jitter never splits a calibration profile, while genuinely different
- /// media generations stay distinct. Examples: 781.47 GB → 780, 402 GB → 400, 1495 GB → 1500,
- /// 2498 GB → 2500. Returns 0 when capacity is unknown (e.g. no media loaded).
+ /// media generations stay distinct. Examples: 781.47 GB → 780GB, 402 GB → 400GB,
+ /// 500 MB → 500MB. Sub-2 GB media are bucketed in MB so small (virtual) test cartridges
+ /// stay distinguishable. Returns 0 when capacity is unknown (e.g. no media loaded).
+ /// Delegates to so a key built by either side matches.
///
- protected static long CapacityBucketGB(long capacityBytes)
- {
- if (capacityBytes <= 0)
- return 0;
-
- double gb = capacityBytes / (1024.0 * 1024 * 1024);
- // Keep 2 significant figures: round to the nearest 10^(floor(log10)-1).
- double mag = Math.Pow(10, Math.Floor(Math.Log10(gb)) - 1);
- if (mag < 1) mag = 1; // never sub-GB granularity
-
- return (long)(Math.Round(gb / mag) * mag);
- }
+ protected static string CapacityBucket(long capacityBytes) => TapeCalibration.CapacityBucket(capacityBytes);
#endregion
diff --git a/TapeLibNET/TapeNavigator.cs b/TapeLibNET/TapeNavigator.cs
index e732544..ac8912e 100644
--- a/TapeLibNET/TapeNavigator.cs
+++ b/TapeLibNET/TapeNavigator.cs
@@ -49,58 +49,6 @@ public long TOCCapacity
set => m_tocCapacityOverride = value;
}
- ///
- /// Adjusts the remaining content capacity accounting for the drive reporting and TOC capacity.
- /// Do not deduct the TOC capacity; the method will do this.
- ///
- ///
- /// The remaining content capacity to adjust without deducted TOC capacity.
- ///
- /// The adjusted remaining content capacity.
- [Obsolete("Phase 3: superseded by TapeDrive.Remaining (calibrated estimate) plus early-warning " +
- "enforcement via TapeDrive.SetEarlyWarning. Retained as a backstop for the legacy capacity checks.")]
- public long AdjustRemainingContentCapacity(long remainingCapacity)
- {
- // Prefer the authoritative calibrated estimate; fall back to the raw driver figure.
- var remainingFromDrive = Drive.Remaining;
- // adjust down by 1% of drive capacity to account for drive reporting inaccuracies
- remainingFromDrive -= Drive.Capacity / 100;
-
- remainingCapacity = Math.Max(remainingCapacity, remainingFromDrive);
-
- if (!Drive.HasInitiatorPartition)
- remainingCapacity -= TOCCapacity;
-
- remainingCapacity = Math.Max(remainingCapacity, 0); // don't return negative capacity
- return remainingCapacity;
- }
-
- ///
- /// Adjusts the remaining content capacity accounting for the drive reporting and TOC capacity.
- /// Do not deduct the TOC capacity; the method will do this.
- ///
- /// The tape drive to use for the adjustment.
- ///
- /// The remaining content capacity to adjust without deducted TOC capacity.
- ///
- /// The adjusted remaining content capacity.
- [Obsolete("Phase 3: superseded by TapeDrive.Remaining (calibrated estimate) plus early-warning " +
- "enforcement via TapeDrive.SetEarlyWarning. Retained as a backstop for the legacy capacity checks.")]
- public static long AdjustRemainingContentCapacity(TapeDrive drive, long remainingCapacity)
- {
- var remainingFromDrive = drive.Remaining;
- // adjust down by 1% of drive capacity to account for drive reporting inaccuracies
- remainingFromDrive -= drive.Capacity / 100;
-
- remainingCapacity = Math.Max(remainingCapacity, remainingFromDrive);
-
- if (!drive.HasInitiatorPartition)
- remainingCapacity -= DefaultTOCCapacity(drive);
-
- remainingCapacity = Math.Max(remainingCapacity, 0); // don't return negative capacity
- return remainingCapacity;
- }
-
private long? m_tocCapacityOverride = null;
public virtual bool TOCInvalidated { get; protected set; } = false;
diff --git a/TapeLibNET/Virtual/VirtualTapeDriveBackend.cs b/TapeLibNET/Virtual/VirtualTapeDriveBackend.cs
index 57966d6..04d38e0 100644
--- a/TapeLibNET/Virtual/VirtualTapeDriveBackend.cs
+++ b/TapeLibNET/Virtual/VirtualTapeDriveBackend.cs
@@ -322,7 +322,13 @@ public override string DeviceName
public override uint DriveNumber => m_driveNumber;
public override string Vendor => Assembly.GetExecutingAssembly().GetName().Name ?? string.Empty;
public override string Product => VTapePrefix; // GetType().Name;
- public override string Revision => Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? string.Empty;
+
+ ///
+ /// Stable emulation identity — deliberately NOT the assembly version, which would change the
+ /// on every build and orphan every saved calibration
+ /// profile. Bump only when the emulation's capacity/EW behavior changes incompatibly.
+ ///
+ public override string Revision => "v1";
///
diff --git a/TapeLibNET/Virtual/VirtualTapeEwProfile.cs b/TapeLibNET/Virtual/VirtualTapeEwProfile.cs
index 87e8c66..0f4485b 100644
--- a/TapeLibNET/Virtual/VirtualTapeEwProfile.cs
+++ b/TapeLibNET/Virtual/VirtualTapeEwProfile.cs
@@ -1,5 +1,42 @@
namespace TapeLibNET.Virtual;
+///
+/// The two INDEPENDENT anchors of an emulated driver's reported-remaining line. See
+/// docs/Design-RemainingAndEw.md §5.1 for the normative vocabulary.
+///
+/// — (a) inflated capacity at BOM: the driver claims
+/// TrueCapacity + boost free on a virgin cartridge, then counts down. Overshoot is a CONSTANT
+/// from the very first byte. Defaults to 0, matching faithful LTO behavior.
+/// — (b) phantom free space at hard EOM: the driver decrements
+/// too slowly, so overshoot GROWS from the boost at BOM to boost + phantom at hard EOM, where it
+/// still claims bytes that do not exist. This is the LTO-4's ~28 GB lie.
+///
+/// The reported line interpolates linearly (monotonic non-increasing) between the two anchors:
+///
+/// reported(0) = TrueCapacity + ReportedCapacityBoost
+/// reported(TrueCapacity) = PhantomFreeAtEom
+///
+///
+public readonly record struct ReportedRemainingAnchors(long ReportedCapacityBoost, long PhantomFreeAtEom)
+{
+ /// The truthful line: no boost at BOM, no phantom free space at EOM.
+ public static ReportedRemainingAnchors Truthful => new(0L, 0L);
+
+ /// Whether these anchors describe any divergence from the truth at all.
+ public bool IsTruthful => ReportedCapacityBoost == 0 && PhantomFreeAtEom == 0;
+
+ /// Linearly interpolates the reported-remaining figure for a true position, floored at zero.
+ public long ReportedRemaining(long actualWritten, long capacity)
+ {
+ if (capacity <= 0)
+ return 0L;
+
+ double atBom = capacity + ReportedCapacityBoost;
+ double slope = (atBom - PhantomFreeAtEom) / capacity;
+ return System.Math.Max(0L, (long)System.Math.Round(atBom - actualWritten * slope));
+ }
+}
+
///
/// Opt-in emulation profile that makes a reproduce the two real-world
/// LTO behaviors the remaining-capacity estimator exists to tame:
@@ -21,10 +58,18 @@ public sealed record VirtualTapeEwProfile
///
public long EarlyWarningZone { get; init; }
+ ///
+ /// The BOM/EOM anchors of the emulated reported-remaining line. This is the preferred, inspectable and
+ /// serializable way to express over-reporting; exists only for
+ /// shapes that a two-point line cannot express (e.g. a real calibration curve).
+ ///
+ public ReportedRemainingAnchors Anchors { get; init; } = ReportedRemainingAnchors.Truthful;
+
///
/// Maps (actualWritten, capacity) to the driver-Remaining figure the medium reports.
/// Should be monotonic non-increasing in actualWritten. When , the medium
- /// reports the exact capacity − actualWritten (legacy behavior).
+ /// falls back to (which, when truthful, yields the exact
+ /// capacity − actualWritten legacy behavior).
///
public System.Func? ReportedRemainingModel { get; init; }
@@ -32,7 +77,7 @@ public sealed record VirtualTapeEwProfile
public long ReportedRemaining(long actualWritten, long capacity)
{
long reported = ReportedRemainingModel?.Invoke(actualWritten, capacity)
- ?? System.Math.Max(0L, capacity - actualWritten);
+ ?? Anchors.ReportedRemaining(actualWritten, capacity);
return System.Math.Max(0L, reported);
}
@@ -44,44 +89,39 @@ public bool IsInEarlyWarningZone(long actualWritten, long capacity)
///
/// A realistic LTO-4-like preset: an EW zone of of capacity, and a
- /// linear reported-remaining model that overshoots toward the tail and floors at
- /// of capacity at hard EOM (mirrors the documented ~3.6% overshoot and
- /// ~4% floor). Independent of the medium's absolute capacity, so it applies to small test cartridges too.
+ /// reported-remaining line pinned by the two independent over-report anchors
+ /// ().
///
- /// The two percentages describe INDEPENDENT axes and do NOT overlap:
+ /// The three percentages describe INDEPENDENT axes and do NOT overlap:
///
/// is a PHYSICAL distance before hard EOM at which early warning
/// begins to assert ( = capacity * ewZonePercent/100). It is the
/// last stretch of REAL, writable medium.
- /// is a REPORTED-REMAINING figure: the phantom free space the
- /// driver still claims once hard EOM is reached (reported(capacity) = capacity * floorPercent/100).
- /// This space is not physically writable.
+ /// is a REPORTED-REMAINING figure: the phantom free space
+ /// the driver still claims once hard EOM is reached
+ /// (reported(capacity) = capacity * phantomFreePercent/100). This space is not physically
+ /// writable. This is the faithful LTO-4 shape and the reason the estimator exists.
+ /// is an INFLATED CAPACITY at BOM: the driver claims
+ /// capacity * (1 + reportedBoostPercent/100) free on a virgin cartridge. Real LTO drives do not
+ /// do this, hence the default of 0 — but the knob exists so the effect can be emulated and the
+ /// estimator proven against it.
///
- /// Because the floor is phantom (over-reported) capacity rather than physical medium, the EW zone
- /// EXCLUDES the floor — the two are orthogonal knobs. With the defaults (both 4.0), EW fires within
- /// the last 4% of real medium, while the driver over-reports ~4% of capacity as still free at hard EOM.
+ /// Because both over-report knobs describe PHANTOM (over-reported) capacity rather than physical medium,
+ /// the EW zone is orthogonal to them.
///
///
- public static VirtualTapeEwProfile Lto4Like(long capacity, double ewZonePercent = 4.0, double floorPercent = 4.0)
+ public static VirtualTapeEwProfile Lto4Like(
+ long capacity, double ewZonePercent = 4.0, double phantomFreePercent = 4.0,
+ double reportedBoostPercent = 0.0)
{
if (capacity < 0) capacity = 0;
- long ewZone = (long)(capacity * ewZonePercent / 100.0);
- double floorFraction = floorPercent / 100.0;
-
- // reported(0) == capacity ; reported(capacity) == floor == capacity*floorFraction.
- // Linear, monotonic decreasing; overshoot (reported − true) grows to floor at EOM.
- long Model(long actualWritten, long cap)
- {
- if (cap <= 0) return 0;
- double slope = 1.0 - floorFraction;
- double reported = cap - actualWritten * slope;
- return (long)System.Math.Round(reported);
- }
return new VirtualTapeEwProfile
{
- EarlyWarningZone = ewZone,
- ReportedRemainingModel = Model,
+ EarlyWarningZone = (long)(capacity * ewZonePercent / 100.0),
+ Anchors = new ReportedRemainingAnchors(
+ ReportedCapacityBoost: (long)(capacity * reportedBoostPercent / 100.0),
+ PhantomFreeAtEom: (long)(capacity * phantomFreePercent / 100.0)),
};
}
@@ -92,15 +132,23 @@ long Model(long actualWritten, long cap)
/// from ; the EW zone from
/// . Both are scaled by
/// targetCapacity / calibration.CapacityActual.
+ ///
+ /// NOTE the DUALITY: a calibration is normally an ESTIMATION artifact, translating reported → actual
+ /// (). Here it is used in the opposite direction, as an
+ /// EMULATION source, translating actual → reported. Both directions ride the same curve, so scaling must
+ /// stay on the actual axis — hence is the scale
+ /// reference, and the fallback is the curve's own top actual anchor, never a reported figure.
+ ///
///
public static VirtualTapeEwProfile FromCalibration(ITapeCalibration calibration, long targetCapacity)
{
System.ArgumentNullException.ThrowIfNull(calibration);
if (targetCapacity < 0) targetCapacity = 0;
+ // Stay on the ACTUAL axis: mixing in a reported figure here would silently mis-scale the model.
long sourceCapacity = calibration.CapacityActual > 0
? calibration.CapacityActual
- : calibration.CapacityReported;
+ : calibration.Curve.Count > 0 ? calibration.Curve[^1].ActualRemaining : 0L;
double scale = sourceCapacity > 0 ? (double)targetCapacity / sourceCapacity : 1.0;
diff --git a/TapeWinNET/CalibrationWindow.xaml b/TapeWinNET/CalibrationWindow.xaml
index 5b21935..fa4f490 100644
--- a/TapeWinNET/CalibrationWindow.xaml
+++ b/TapeWinNET/CalibrationWindow.xaml
@@ -45,6 +45,7 @@
+
@@ -54,17 +55,20 @@
-
-
+
+
-
-
+
+
+
+
+
-
-
+
+
diff --git a/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs b/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
index 588b3da..352f241 100644
--- a/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
+++ b/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
@@ -63,7 +63,7 @@ public CalibrationCurveControl()
{
Stroke = Brushes.DarkOrange,
StrokeThickness = 1.5,
- StrokeDashArray = new DoubleCollection { 4, 2 },
+ StrokeDashArray = [4, 2],
IsHitTestVisible = false,
Visibility = Visibility.Collapsed,
};
@@ -115,7 +115,7 @@ private void Redraw()
}
ITapeCalibration calibration = Calibration;
- long reportedMax = Math.Max(1L, calibration.CapacityReported);
+ long reportedMax = Math.Max(1L, calibration.ReportedCapacityTotal);
long actualMax = Math.Max(1L, calibration.CapacityActual);
long ewReported = calibration.EarlyWarning?.ReportedRemaining ?? 0L;
diff --git a/TapeWinNET/MainWindow.xaml b/TapeWinNET/MainWindow.xaml
index 064e589..331a6e3 100644
--- a/TapeWinNET/MainWindow.xaml
+++ b/TapeWinNET/MainWindow.xaml
@@ -1192,6 +1192,10 @@
+
+
+
+
diff --git a/TapeWinNET/OpenVirtualDriveWindow.xaml b/TapeWinNET/OpenVirtualDriveWindow.xaml
index 5892144..90806dc 100644
--- a/TapeWinNET/OpenVirtualDriveWindow.xaml
+++ b/TapeWinNET/OpenVirtualDriveWindow.xaml
@@ -371,6 +371,7 @@
+
@@ -381,9 +382,10 @@
SelectedItem="{Binding SelectedEwProfile}"
Width="200" HorizontalAlignment="Left" Margin="0,0,8,6"/>
-
+
-
-
+
+ ToolTip="Free space the emulated driver still claims once hard end-of-media is reached. This space does not exist. A real LTO-4 claims ~28 GB — this is what calibration measures and corrects."
+ VerticalAlignment="Center" Margin="0,0,8,6"/>
+
+
+
+
+
+
-
-
diff --git a/TapeWinNET/ViewModels/BackupMediaUsageBarPresenter.cs b/TapeWinNET/ViewModels/BackupMediaUsageBarPresenter.cs
index 2e3329d..e678b03 100644
--- a/TapeWinNET/ViewModels/BackupMediaUsageBarPresenter.cs
+++ b/TapeWinNET/ViewModels/BackupMediaUsageBarPresenter.cs
@@ -79,15 +79,12 @@ protected override void AddContentSegments(List segments, TapeTOC?
}
// 5. Compute the room actually available for the pending segment.
- // Reserve the trailing TOC (added later by base.BuildSegments) and
- // one byte for the visible Free remnant.
- long capacity = _tapeService.Capacity;
+ // Work on the ESTIMATED capacity axis (the true medium size, phantom free space removed), then
+ // let ComputeWritableRemaining reserve the trailing TOC (added later by base.BuildSegments).
+ // One further byte is held back for the visible Free remnant.
+ long capacity = _tapeService.EstimatedCapacity;
long usedAfterDrop = segments.Sum(s => s.Size);
- long trailingTocReserve = _tapeService.HasInitiatorPartition
- ? 0
- : _tapeService.DefaultTOCCapacity;
- long available = capacity - usedAfterDrop;
- available = _tapeService.AdjustRemainingContentCapacity(available) - 1;
+ long available = _tapeService.ComputeWritableRemaining(capacity - usedAfterDrop) - 1;
if (available < 1) available = 1;
bool fits = pendingSize <= available;
diff --git a/TapeWinNET/ViewModels/CalibrationViewModel.cs b/TapeWinNET/ViewModels/CalibrationViewModel.cs
index 41580be..b3efd38 100644
--- a/TapeWinNET/ViewModels/CalibrationViewModel.cs
+++ b/TapeWinNET/ViewModels/CalibrationViewModel.cs
@@ -61,9 +61,9 @@ public bool IsConfirmChecked
public string Revision => string.IsNullOrWhiteSpace(_tapeService.DeviceRevision) ? "Unknown" : _tapeService.DeviceRevision;
public string ProfileKey => string.IsNullOrWhiteSpace(_tapeService.DriveProfileKey) ? "(unknown)" : _tapeService.DriveProfileKey;
public string CapacityDisplay => Helpers.BytesToStringLong(_tapeService.Capacity);
- public string CapacityBucketDisplay => $"{TapeCalibration.CapacityBucketGB(_tapeService.Capacity):N0} GB bucket";
- public WarningLevel WarningLevel => WarningLevel.Error;
- public string WarningMessage =>
+ public string CapacityBucketDisplay => $"{TapeCalibration.CapacityBucket(_tapeService.Capacity)} bucket";
+ public static WarningLevel WarningLevel => WarningLevel.Error;
+ public static string WarningMessage =>
"Calibration writes the scratch cartridge to end-of-media and destroys any existing content.\r\n" +
"Use only expendable media dedicated to calibration.";
@@ -80,7 +80,8 @@ private set
return;
OnPropertyChanged(nameof(Calibration));
- OnPropertyChanged(nameof(CapacityReportedDisplay));
+ OnPropertyChanged(nameof(ReportedCapacityAtBomDisplay));
+ OnPropertyChanged(nameof(PhantomFreeAtEomDisplay));
OnPropertyChanged(nameof(CapacityActualDisplay));
OnPropertyChanged(nameof(EarlyWarningDisplay));
OnPropertyChanged(nameof(EwToEomDistanceDisplay));
@@ -91,8 +92,13 @@ private set
public ITapeCalibration? Calibration => Result?.Calibration;
- public string CapacityReportedDisplay =>
- Result is not null ? Helpers.BytesToStringLong(Result.CapacityReported) : "—";
+ /// What the driver claimed was free on the virgin cartridge (quantity (4)).
+ public string ReportedCapacityAtBomDisplay =>
+ Result is not null ? Helpers.BytesToStringLong(Result.ReportedCapacityAtBom) : "—";
+
+ /// The headline result: phantom free space still claimed at hard EOM (quantity (5)).
+ public string PhantomFreeAtEomDisplay =>
+ Result is not null ? Helpers.BytesToStringLong(Result.PhantomFreeAtEom) : "—";
public string CapacityActualDisplay =>
Result is not null ? Helpers.BytesToStringLong(Result.CapacityActual) : "—";
diff --git a/TapeWinNET/ViewModels/DeleteBackupSetsViewModel.cs b/TapeWinNET/ViewModels/DeleteBackupSetsViewModel.cs
index 8a2ffe5..38a9126 100644
--- a/TapeWinNET/ViewModels/DeleteBackupSetsViewModel.cs
+++ b/TapeWinNET/ViewModels/DeleteBackupSetsViewModel.cs
@@ -34,7 +34,7 @@ public DeleteBackupSetsViewModel(
_onCancel = onCancel;
_mediaCapacity = tapeService.Capacity;
- _mediaRemaining = tapeService.Remaining;
+ _mediaRemaining = tapeService.WritableRemaining;
PopulateDeleteOptions();
diff --git a/TapeWinNET/ViewModels/MainViewModel.cs b/TapeWinNET/ViewModels/MainViewModel.cs
index d8ba18c..0630d5d 100644
--- a/TapeWinNET/ViewModels/MainViewModel.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.cs
@@ -47,6 +47,7 @@ public partial class MainViewModel : ViewModelBase
private readonly MruFileList _virtualDriveMru;
private string _windowTitle = "TapeWin - Tape Backup Manager";
private string _statusMessage = "Ready";
+ private string? _remainingAndEw;
private string _busyMessage = string.Empty;
private string _propertiesHeader = "Properties";
private string _tableHeader = "Content";
@@ -184,7 +185,30 @@ public string WindowTitle
public string StatusMessage
{
get => _statusMessage;
- set => SetProperty(ref _statusMessage, value);
+ set
+ {
+ SetProperty(ref _statusMessage, value);
+ // The remaining/EW indication is re-evaluated whenever the status changes,
+ // since every status transition follows a drive/media/TOC operation.
+ RefreshRemainingAndEw();
+ }
+ }
+
+ ///
+ /// Status-bar and property-pane indication of the estimated remaining capacity plus the
+ /// end-of-tape policy in force. Empty when no media is loaded (the field then hides).
+ ///
+ public string? RemainingAndEw
+ {
+ get => _remainingAndEw;
+ private set => SetProperty(ref _remainingAndEw, value);
+ }
+
+ /// Re-reads the remaining/EW indication from the service (null when unavailable, so the field hides).
+ private void RefreshRemainingAndEw()
+ {
+ string status = _tapeService.RemainingAndEwStatus;
+ RemainingAndEw = string.IsNullOrEmpty(status) ? null : status;
}
public string BusyMessage
@@ -1494,10 +1518,7 @@ private void LoadDriveInfo()
{
PropertyList.Add(new PropertyItem("Partition Count",
_tapeService.PartitionCount.ToString()));
- PropertyList.Add(new PropertyItem("Capacity",
- Helpers.BytesToStringLong(_tapeService.Capacity)));
- PropertyList.Add(new PropertyItem("Remaining (est.)",
- Helpers.BytesToStringLong(_tapeService.GetRemainingCapacityFromDrive())));
+ AddCapacityProperties();
}
}
@@ -1508,6 +1529,31 @@ private void LoadDriveInfo()
AppendRemoteConnectionInfo();
}
+ ///
+ /// Appends the shared capacity block to , using the strict semantics of
+ /// docs/Design-RemainingAndEw.md §5.1: the driver's optimistic REPORTED figures are shown beside our
+ /// corrected ESTIMATES, and the WRITABLE space — the number the user actually spends — is called out
+ /// on its own row, followed by the provenance of the estimate.
+ ///
+ /// Reported and estimated are never mixed within one row's arithmetic; each is quoted on its own axis.
+ ///
+ ///
+ private void AddCapacityProperties()
+ {
+ static string pair(long reported, long estimated)
+ => $"{Helpers.BytesToStringLong(reported)} / {Helpers.BytesToStringLong(estimated)}";
+
+ PropertyList.Add(new PropertyItem("Capacity reported / estimated",
+ pair(_tapeService.Capacity, _tapeService.EstimatedCapacity)));
+ PropertyList.Add(new PropertyItem("Remaining reported / estimated",
+ pair(_tapeService.ReportedContentRemaining, _tapeService.EstimatedContentRemaining)));
+ // The headline figure — highlighted because it is the one the user plans a backup against.
+ PropertyList.Add(new PropertyItem("Writable",
+ Helpers.BytesToStringLong(_tapeService.WritableRemaining),
+ isHighlighted: true));
+ PropertyList.Add(new PropertyItem("Estimation by", _tapeService.RemainingEstimationSource));
+ }
+
private void LoadMediaInfo()
{
PropertyList.Clear();
@@ -1530,14 +1576,8 @@ private void LoadMediaInfo()
PropertyList.Add(new PropertyItem("Created On", toc.CreationTime.ToString("G")));
PropertyList.Add(new PropertyItem("Last Saved", toc.LastSaveTime.ToString("G")));
PropertyList.Add(new PropertyItem("Backup Sets", toc.Count.ToString()));
- PropertyList.Add(new PropertyItem("Capacity",
- Helpers.BytesToStringLong(_tapeService.Capacity)));
-
- var used = _tapeService.Used;
- var remaining = _tapeService.Remaining;
-
- PropertyList.Add(new PropertyItem("Used", Helpers.BytesToStringLong(used)));
- PropertyList.Add(new PropertyItem("Remaining", Helpers.BytesToStringLong(remaining)));
+ PropertyList.Add(new PropertyItem("Used", Helpers.BytesToStringLong(_tapeService.Used)));
+ AddCapacityProperties();
PropertyList.Add(new PropertyItem("TOC Placement",
_tapeService.IsTOCFromFile
? $"File: {_tapeService.TOCFilePath}"
diff --git a/TapeWinNET/ViewModels/MediaUsageBarPresenter.cs b/TapeWinNET/ViewModels/MediaUsageBarPresenter.cs
index 21619d8..5ae0372 100644
--- a/TapeWinNET/ViewModels/MediaUsageBarPresenter.cs
+++ b/TapeWinNET/ViewModels/MediaUsageBarPresenter.cs
@@ -166,15 +166,15 @@ protected virtual void BuildSegments(List segments, TapeTOC? toc,
if (pending)
{
- // Don't use _tapeService.AdjustRemainingContentCapacity(free) as it can't know about
- // to-be-added backup set / TOC and will retun the (adjusted) drive's reported free space
- free = Math.Max(free, 0); // avoid negative free space
+ // Pending (to-be-added) sets/TOC are already counted in usedSoFar, so the remainder IS the
+ // what-if free space — no further TOC reservation, just clamp.
+ free = Math.Max(free, 0);
}
else
{
- free += tocSize; // AdjustRemainingContentCapacity() will account for TOC size
- // Adjust free space to account for the TOC's reserved capacity.
- free = _tapeService.AdjustRemainingContentCapacity(free);
+ // Hand ComputeWritableRemaining the free space WITHOUT the TOC deducted; it reserves the TOC
+ // itself when the TOC shares the content partition.
+ free = _tapeService.ComputeWritableRemaining(free + tocSize);
}
segments.Add(new UsageSegment(
diff --git a/TapeWinNET/ViewModels/OpenVirtualDriveViewModel.cs b/TapeWinNET/ViewModels/OpenVirtualDriveViewModel.cs
index d06ff9d..36db576 100644
--- a/TapeWinNET/ViewModels/OpenVirtualDriveViewModel.cs
+++ b/TapeWinNET/ViewModels/OpenVirtualDriveViewModel.cs
@@ -190,13 +190,21 @@ public sealed record EwProfileOption(string Display, bool EnableEw, ITapeCalibra
///
/// Builds the emulation profile for a target . For
- /// the caller passes explicit / ; for the
- /// LTO-4 preset those are derived as percentages; for calibration options they are taken from the profile.
- /// models phantom free space the driver still claims at hard EOM —
- /// it does not reduce the medium's true writable capacity.
+ /// the caller passes the explicit knobs; for the LTO-4 preset those are derived as percentages; for
+ /// calibration options they are taken from the profile.
+ ///
+ /// The two over-report knobs are INDEPENDENT and neither reduces the medium's true writable capacity:
+ ///
+ /// — phantom free space the driver still claims at hard
+ /// EOM. This is the faithful LTO shape.
+ /// — capacity inflated at BOM, i.e. an overshoot
+ /// present from the very first byte. Real LTO drives do not do this, hence the default of 0.
+ ///
+ ///
/// Returns when no meaningful emulation is configured (e.g. Custom with zero zone).
///
- public VirtualTapeEwProfile? BuildProfile(long capacityBytes, long ewZoneBytes, long overreportBytes)
+ public VirtualTapeEwProfile? BuildProfile(long capacityBytes, long ewZoneBytes,
+ long phantomFreeAtEomBytes, long reportedCapacityBoostBytes = 0)
{
if (!EnableEw)
return null;
@@ -209,15 +217,17 @@ public sealed record EwProfileOption(string Display, bool EnableEw, ITapeCalibra
if (IsCustom)
{
- if (ewZoneBytes <= 0 && overreportBytes <= 0)
+ if (ewZoneBytes <= 0 && phantomFreeAtEomBytes <= 0 && reportedCapacityBoostBytes <= 0)
return null;
- double ewZonePercent = 100.0 * ewZoneBytes / capacityBytes;
- double floorPercent = 100.0 * overreportBytes / capacityBytes;
- return VirtualTapeEwProfile.Lto4Like(capacityBytes, ewZonePercent, floorPercent);
+ return VirtualTapeEwProfile.Lto4Like(
+ capacityBytes,
+ ewZonePercent: 100.0 * ewZoneBytes / capacityBytes,
+ phantomFreePercent: 100.0 * phantomFreeAtEomBytes / capacityBytes,
+ reportedBoostPercent: 100.0 * reportedCapacityBoostBytes / capacityBytes);
}
- // Built-in LTO-4 preset (default 4% EW zone, 4% floor).
+ // Built-in LTO-4 preset (4% EW zone, 4% phantom free at EOM, no BOM boost).
return VirtualTapeEwProfile.Lto4Like(capacityBytes);
}
}
diff --git a/TapeWinNET/ViewModels/VirtualDriveConfigViewModelBase.cs b/TapeWinNET/ViewModels/VirtualDriveConfigViewModelBase.cs
index 27c28c8..1005139 100644
--- a/TapeWinNET/ViewModels/VirtualDriveConfigViewModelBase.cs
+++ b/TapeWinNET/ViewModels/VirtualDriveConfigViewModelBase.cs
@@ -38,12 +38,17 @@ public abstract class VirtualDriveConfigViewModelBase : ViewModelBase
protected string _mediaName = $"Virtual media created {DateTime.Now:yyyy-MM-dd HH:mm}";
- // End-of-media emulation (early warning zone + capacity overreport)
+ // End-of-media emulation: the EW zone plus the two INDEPENDENT over-report anchors
+ // (see docs/Design-RemainingAndEw.md §5.1).
protected EwProfileOption _selectedEwProfile = EwProfileOption.None;
- protected string _ewZoneValue = "4";
- protected CapacityUnit _ewZoneUnit = CapacityUnit.Percent;
- protected string _overreportValue = "4";
- protected CapacityUnit _overreportUnit = CapacityUnit.Percent;
+ protected string _ewZoneValue = "4";
+ protected CapacityUnit _ewZoneUnit = CapacityUnit.Percent;
+ // (b) Phantom free space still claimed at hard EOM — the faithful LTO shape, hence a 4% default.
+ protected string _phantomFreeValue = "4";
+ protected CapacityUnit _phantomFreeUnit = CapacityUnit.Percent;
+ // (a) Capacity inflated at BOM — real LTO drives do not do this, hence a 0 default.
+ protected string _overreportValue = "0";
+ protected CapacityUnit _overreportUnit = CapacityUnit.Percent;
// ── Commands ──────────────────────────────────────────────────────────────
@@ -207,6 +212,7 @@ public EwProfileOption SelectedEwProfile
{
OnPropertyChanged(nameof(IsEwCustom));
OnPropertyChanged(nameof(EwZoneBytesDisplay));
+ OnPropertyChanged(nameof(PhantomFreeBytesDisplay));
OnPropertyChanged(nameof(OverreportBytesDisplay));
}
}
@@ -242,6 +248,35 @@ public CapacityUnit EwZoneUnit
}
}
+ ///
+ /// (b) Phantom free space the emulated driver still claims at hard EOM — the LTO-4's ~28 GB lie, and the
+ /// reason the estimator exists. Over-reported space only; it never reduces the medium's true capacity.
+ ///
+ public string PhantomFreeValue
+ {
+ get => _phantomFreeValue;
+ set
+ {
+ if (SetProperty(ref _phantomFreeValue, value))
+ OnPropertyChanged(nameof(PhantomFreeBytesDisplay));
+ }
+ }
+
+ public CapacityUnit PhantomFreeUnit
+ {
+ get => _phantomFreeUnit;
+ set
+ {
+ if (SetProperty(ref _phantomFreeUnit, value))
+ OnPropertyChanged(nameof(PhantomFreeBytesDisplay));
+ }
+ }
+
+ ///
+ /// (a) Capacity inflated at BOM — how much MORE than the true capacity the emulated driver claims is free
+ /// on a virgin cartridge, i.e. a constant overshoot present from the very first byte. Usually left at 0,
+ /// since real LTO drives report their capacity honestly at BOM.
+ ///
public string OverreportValue
{
get => _overreportValue;
@@ -265,18 +300,21 @@ public CapacityUnit OverreportUnit
/// EW-zone size resolved to bytes against the current content capacity.
public long EwZoneBytes => _ewZoneUnit.ToBytes(_ewZoneValue, ContentCapacityBytes);
- ///
- /// Capacity-overreport size resolved to bytes against the current content capacity — i.e. the
- /// phantom free space the emulated driver may still claim at hard EOM, not a reduction of the
- /// medium's true writable capacity.
- ///
+ /// (b) Phantom-free-at-EOM size resolved to bytes against the current content capacity.
+ public long PhantomFreeBytes => _phantomFreeUnit.ToBytes(_phantomFreeValue, ContentCapacityBytes);
+
+ /// (a) Capacity-overreport size resolved to bytes against the current content capacity.
public long OverreportBytes => _overreportUnit.ToBytes(_overreportValue, ContentCapacityBytes);
- public string EwZoneBytesDisplay =>
- IsEwCustom ? EwZoneUnit == CapacityUnit.Percent ? $"= {Helpers.BytesToString(EwZoneBytes)}" : $"= {EwZoneBytes:N0} bytes" : string.Empty;
+ public string EwZoneBytesDisplay => FormatEwBytes(EwZoneUnit, EwZoneBytes);
+ public string PhantomFreeBytesDisplay => FormatEwBytes(PhantomFreeUnit, PhantomFreeBytes);
+ public string OverreportBytesDisplay => FormatEwBytes(OverreportUnit, OverreportBytes);
- public string OverreportBytesDisplay =>
- IsEwCustom ? OverreportUnit == CapacityUnit.Percent ? $"= {Helpers.BytesToString(OverreportBytes)}" : $"= {OverreportBytes:N0} bytes" : string.Empty;
+ /// Shared "= 31.2 GB" / "= 33,554,432 bytes" hint shown beside each EW input.
+ private string FormatEwBytes(CapacityUnit unit, long bytes) =>
+ !IsEwCustom ? string.Empty
+ : unit == CapacityUnit.Percent ? $"= {Helpers.BytesToString(bytes)}"
+ : $"= {bytes:N0} bytes";
///
/// Builds the for the current content capacity and EW settings, or
@@ -284,7 +322,7 @@ public CapacityUnit OverreportUnit
/// the IO-rate emulation.
///
public VirtualTapeEwProfile? BuildEwProfile() =>
- _selectedEwProfile.BuildProfile(ContentCapacityBytes, EwZoneBytes, OverreportBytes);
+ _selectedEwProfile.BuildProfile(ContentCapacityBytes, EwZoneBytes, PhantomFreeBytes, OverreportBytes);
///
/// Appends stored calibration profiles to . Non-throwing: a store failure simply
@@ -306,6 +344,7 @@ protected void AddCalibrationProfiles(IEnumerable? calibration
private void OnEwBaseCapacityChanged()
{
OnPropertyChanged(nameof(EwZoneBytesDisplay));
+ OnPropertyChanged(nameof(PhantomFreeBytesDisplay));
OnPropertyChanged(nameof(OverreportBytesDisplay));
}
diff --git a/docs/Design-RemainingAndEw.md b/docs/Design-RemainingAndEw.md
index 8bfa881..b7241bc 100644
--- a/docs/Design-RemainingAndEw.md
+++ b/docs/Design-RemainingAndEw.md
@@ -1,4 +1,4 @@
-# TapeLibNET — Remaining-Capacity Estimation & Early Warning
+# TapeLibNET — Remaining-Capacity Estimation & Early Warning
Complete design specification for the capacity-estimation and early-warning subsystem of `TapeDrive`.
This document complements the TapeNET Context Primer and follows its conventions.
@@ -160,9 +160,11 @@ compares a profile key); the concrete type is JSON-serialized inside TapeLibNET.
| Member | Role |
|---|---|
-| `FormatId` | Format + version guard (`tapelibnet-cal/1`); loader rejects unknown ids. |
-| `ProfileKey` | `vendor\|product\|revision\|cap=NNNGB` — identifies the drive+media profile. |
-| `CapacityReported` | Driver capacity at BOT. |
+| `FormatId` | Format + version guard (`tapelibnet-cal/2`); loader rejects unknown ids. |
+| `ProfileKey` | `vendor\|product\|revision\|cap=` — identifies the drive+media profile (see Part 5.3). |
+| `ReportedCapacityAtBom` | The driver's claimed capacity at beginning of media. |
+| `PhantomFreeAtEom` | Reported remaining at the instant hard EOM fires — space the driver claims but that does not exist. |
+| `ReportedCapacityTotal` | Derived: `CapacityActual + PhantomFreeAtEom`. |
| `CapacityActual` | Bytes written at hard EOM — the ground truth. |
| `Curve` | `ReportedRemaining → ActualRemaining` points, sorted ascending, conservative on ties. |
| `EarlyWarning` | Nullable `(ReportedRemaining, ActualRemaining)` landmark; null if the drive never reported EW. |
@@ -283,9 +285,8 @@ low-level `TapeDriveWin32Backend.lto-direct.cs`.
round-trips. The plan adds a lightweight throttle/cache (reuse the existing `m_cachedContentRemaining` path)
so Service-layer polling stays cheap. --> implemented caching `m_mediaParams` -- invalidate on every write.
S. `EnsureMediaParams()`, `InvalidateMediaParams()`, `ReloadMediaParams()`. Additional block size caching to accelerate `BlockSize` getter.
-- [ ] WIP **`TapeDrive.Remaining` does not exist; callers use `GetRemainingCapacity()` + Navigator adjustment.** The
- integration introduces a single authoritative property (see Phase 3) rather than leaving three competing
- notions (`GetRemainingCapacity`, `GetContentRemainingCapacity`, `AdjustRemainingContentCapacity`) --> address during integration (Phase 3)
+- [v] DONE **A single authoritative remaining API.** The three competing notions were replaced by the
+ `Reported*` / `Estimated*` / `Writable*` naming rule described in Part 5.4.
- [v] DONE **`EarlyWarning` setter silently no-ops without media.** `SetEarlyWarning` returns `false` and sets
`ERROR_NO_MEDIA_IN_DRIVE`, but the property setter swallows the result. Document that the reserve is only
applied once media is loaded, and have the Service layer (re)apply the desired reserve in `PrepareMedia`. -->
@@ -318,23 +319,20 @@ Phase 2 and only needs a stub so the `Write` signature stays honest.)
- **New emulation profile on `VirtualTapeDriveBackend`** (opt-in, defaults preserve current exact behavior):
- `EarlyWarningZone` (bytes before physical EOM at which built-in EW starts firing; e.g. `~4%` of capacity).
Null/0 ⇒ no EW emulation (legacy behavior).
- - `ReportedRemainingModel` — a delegate/curve mapping *true* `bytesWritten` → *reported* `Remaining` that
- overshoots and floors near the tail (model the LTO-4 ~3.6% overshoot, monotonic, floored ≈ 32/50 of
- capacity as in the doc). Default ⇒ the current exact `capacity − bytesWritten`.
- - Consider leveraging the existing `ITapeCalibration` mechanism and, if necessary, `TapeCalibration`
- implementation to implement `ReportedRemainingModel`. This way we can apply both synthetic (`Apriori`) or
- real-life measured calibration data to exactly model the behavior. Effectively, we'll need to flip
- `TranslateRemaining()` functionality to `TranslateActualToReported()`. A catch to address: The real-life
- profiles originate from large-capacity media, 100s GB. Introduce a wrapper that maps the profile's
- original capacity to any other, smaller, value -- to apply the profile to a generally much smaller virtual
- drive. It's acceptable to unseal `TapeCalibration` if deriving proves more elegant than adding additional
- methods.
+ - `Anchors` — the two endpoints of the actual→reported line (`ReportedCapacityBoost` at BOM,
+ `PhantomFreeAtEom` at hard EOM) mapping *true* `bytesWritten` → *reported* `Remaining`, so the reported
+ figure overshoots toward the tail as the real LTO-4 does. Truthful anchors ⇒ exact
+ `capacity − bytesWritten`.
+ - Leverage the existing `ITapeCalibration` mechanism so both synthetic (`Apriori`) and real-life measured
+ calibration data can drive the emulation, flipping `TranslateRemaining()` into
+ `TranslateActualToReported()`. A catch to address: real-life profiles originate from large-capacity media,
+ 100s GB, so a wrapper maps the profile's original capacity onto the generally much smaller virtual drive.
- **`WriteBlocks` / `Write` semantics:**
- When `bytesWritten` enters the EW zone (but capacity remains) → set `ew = true`, data **is** written,
no error (mirrors the real sense-key ⇒ EW mapping). EW keeps firing on every subsequent write to EOM.
- When truly full → keep the existing `ERROR_END_OF_MEDIA` ⇒ `eom = true` (data rejected).
- `pew` remains `false` (Phase-2 stub); leave a single `// Phase 2` marker replacing the current `FIXME`.
-- **`Remaining` property** returns the `ReportedRemainingModel` value (the "quirky" figure the driver would
+- **`Remaining` property** returns the anchored model value (the "quirky" figure the driver would
report), while the drive / media internally still tracks true `bytesWritten` for capacity enforcement.
- **`EarlyWarningMechanism`** on the virtual backend returns `HardwareEarlyWarning` when the EW zone is
configured, else `None`. `ReportEarlyWarning(bool)` records the request and gates whether `ew` is surfaced.
@@ -370,12 +368,13 @@ An opt-in, immutable emulation profile — a `record` — carried by the media.
behavior. Key design points:
- **Two knobs:** `EarlyWarningZone` (bytes before physical EOM at which built-in EW starts asserting) and
- `ReportedRemainingModel` — a `Func` that should be monotonic
- non-increasing. Null model ⇒ exact `capacity − actualWritten` (legacy). Both are floored at zero.
-- **`Lto4Like(capacity, ewZonePercent = 4, floorPercent = 4)`** — a realistic preset independent of absolute
- capacity (so it applies to tiny test cartridges too). A single linear map `reported = capacity −
- actualWritten·(1 − floorFraction)` overshoots toward the tail and floors at `floorPercent` of capacity at
- hard EOM (mirrors the documented ~3.6 % overshoot / ~4 % floor of the LTO-4).
+ `Anchors` — a `ReportedRemainingAnchors` record naming the two endpoints of the actual→reported line
+ (`ReportedCapacityBoost` at BOM, `PhantomFreeAtEom` at hard EOM; see Part 5.2), interpolated linearly and
+ monotonic non-increasing. Truthful anchors (both zero) ⇒ exact `capacity − actualWritten`. Both are
+ floored at zero.
+- **`Lto4Like(capacity, ewZonePercent = 4, phantomFreePercent = 4, reportedBoostPercent = 0)`** — a realistic
+ preset independent of absolute capacity (so it applies to tiny test cartridges too), mirroring the
+ documented ~3.6 % overshoot growing toward the tail with a truthful figure at BOM.
- **`FromCalibration(ITapeCalibration, targetCapacity)`** — the elegant path the draft asked for: it derives
the model from a real (or `Apriori`) calibration by **rescaling** the profile's large capacity onto the small
virtual cartridge (`scale = targetCapacity / CapacityActual`). The reported figure is produced by
@@ -428,15 +427,18 @@ Goal: Extend `OpenVirtualDriveWindow` and `VirtualDriveConfigViewModelBase` to a
emulation behavior -- implemented using the mechanism implemented in Phase 1.
- **UI**: A new Groupbox "Emulate End-of-Media Behavior", placed under the "Emulate IO Performance" Groupbox:
- - **Capacity Overreport**: a numeric input to specify the value in "% Capacity" or directly in MB / GB;
- unit choosable by a small combobox similar to Capacity input. 4% by default. A textbox shows the value in bytes,
- also similar to the Capacity input.
- - **Early Warning Zone**: a numeric input similar to the above. 4% by default.
+ - **Early Warning Zone**: a numeric input to specify the value in "% Capacity" or directly in MB / GB;
+ unit choosable by a small combobox similar to the Capacity input. 4% by default. A textbox shows the value
+ in bytes, also similar to the Capacity input.
+ - **Phantom free at EOM**: a numeric input similar to the above — the space the emulated driver still claims
+ when hard EOM fires. 4% by default.
+ - **Capacity overreport (BOM)**: a numeric input similar to the above — the inflation of the driver's claimed
+ capacity at beginning of media. 0 by default, and listed last since it is usually left at 0.
- **Profile**: Combobox populated by emulation profiles:
- - 1st entry is `[Custom]` which allows the user to specify the above two values.
+ - 1st entry is `[Custom]` which allows the user to specify the above values.
- 2nd entry is `[LTO-4]` generated by the `Lto4Like` factory.
- The other entries are the calibration profiles loaded from the app's persistent storage.
- Selecting a profile other than `[Custom]` disables **and blanks** the two value inputs (they are collapsed
+ Selecting a profile other than `[Custom]` disables **and blanks** the value inputs (they are collapsed
via `BoolToVis`). Non-`[Custom]` profiles are opaque by design -- their internal reported-remaining curve is a
set of samples, not a simple pair of values, so surfacing derived numbers would misrepresent them.
@@ -446,14 +448,15 @@ The updated spec was reviewed against the actual code before implementation; sev
hold and were corrected in code:
1. **No new TapeLibNET factory needed.** The `[Custom]` and `[LTO-4]` options both build via the existing
- `VirtualTapeEwProfile.Lto4Like(capacity, ewZonePercent, floorPercent)`; calibration options build via
- `VirtualTapeEwProfile.FromCalibration(cal, capacity)`. The UI resolves the two inputs (EW zone, overreport)
- to *percentages of content capacity* and passes them as `ewZonePercent` / `floorPercent`.
+ `VirtualTapeEwProfile.Lto4Like(capacity, ewZonePercent, phantomFreePercent, reportedBoostPercent)`;
+ calibration options build via `VirtualTapeEwProfile.FromCalibration(cal, capacity)`. The UI resolves the
+ three inputs to *percentages of content capacity* and passes them through.
-2. **`Lto4Like` semantics clarified.** The EW zone and the floor (overreport) are **independent, non-overlapping**
- axes: the EW zone is a *physical* distance before hard EOM (last `ewZonePercent%` of real medium), while the
- floor is a *reported-remaining* figure (phantom free space still claimed at hard EOM). The EW zone therefore
- **EXCLUDES** the floor. This is now documented on the `Lto4Like` factory.
+2. **`Lto4Like` semantics.** The EW zone and the two over-report anchors are **independent, non-overlapping**
+ axes: the EW zone is a *physical* distance before hard EOM (last `ewZonePercent%` of real medium), while
+ the anchors are *reported-remaining* figures (inflation at BOM and phantom free space still claimed at hard
+ EOM). The EW zone therefore **EXCLUDES** the phantom free space. This is documented on the `Lto4Like`
+ factory.
3. **`% Capacity` is not a `CapacityUnit` multiplier.** A percentage has no constant byte multiplier, so it could
not be added to `CapacityUnit.All`. Instead `CapacityUnit` gained a `Percent` sentinel (multiplier 0), an
@@ -567,15 +570,15 @@ Two test-only realities were also confirmed (and documented in the tests):
Make the improved estimate the *default* remaining-capacity figure the rest of the library and apps consume, and
retire the ad-hoc `AdjustRemainingContentCapacity` heuristic.
-- **New authoritative property `TapeDrive.Remaining`** ⇒ returns `EstimateActualRemaining()` (calibrated when
- available, raw driver value otherwise), throttled/cached per Phase 0.
-- **`TapeDrive.DriverReportedRemaining`** ⇒ the raw `GetRemainingCapacity()` value, kept for diagnostics,
- calibration, and UI "driver says vs. we estimate" display.
-- **Deprecate `TapeNavigator.AdjustRemainingContentCapacity`** (instance + static): mark `[Obsolete]` and route
- its callers to the new estimate. The TOC-reservation deduction it performed (for TOC-in-set) is replaced by
+- **New authoritative property `TapeDrive.EstimatedContentRemaining`** ⇒ returns `EstimateActualRemaining()`
+ (calibrated when available, a-priori otherwise), throttled/cached per Phase 0.
+- **`TapeDrive.ReportedContentRemaining`** ⇒ the raw `GetReportedContentRemaining()` value, kept for
+ diagnostics, calibration, and the UI's paired "reported / estimated" display.
+- **Retire `TapeNavigator.AdjustRemainingContentCapacity`** (instance + static): its callers move to the new
+ estimate. The TOC-reservation deduction it performed (for TOC-in-set) is replaced by
setting `TapeDrive.SetEarlyWarning`.
- - `TapeBackupAgent.ComputeRemainingCapacity`: `Drive.Remaining − (HasInitiatorPartition ? 0 : TOCCapacity)`,
- clamped ≥ 0.
+ - `TapeBackupAgent.ComputeRemainingCapacity`: `Drive.EstimatedContentRemaining −
+ (HasInitiatorPartition ? 0 : TOCCapacity)`, clamped ≥ 0.
- **Two scenarios** using capacity estoimation we need to *both* address -- yet analyze *separately* -- in `TapeBackupAgent` and `TapeStreamManager` path:
- The legacy "aligned" file / `TapeFileStream` storing -- still in use for TOC writing and in tests. The agent performs the writing directly to the stream produced by `TapeStreamManager`..
- The mainstream "packed" file storing: `TapeStreamManager.PackerWriteSink` performs the writing on behalf of the packer (called by the packer).
@@ -584,8 +587,9 @@ retire the ad-hoc `AdjustRemainingContentCapacity` heuristic.
In the legacy path, should add reaction to the EW when writing content streams -- the same way we react to EOM now. This will make the "fit" checking in `ProduceWriteContentStream` unnecessary! When writing TOC, we should still only react to EOM ignoring EW (maybe just trace it).
- **Where to set the EW size, to what?** A natural place seems in `BeginWriteContentForCurrentSet`. The size itself should be the TOC size for TOC-in-set or 0 (EW not needed) for TOC-in-partition.
- **Decide how to wire logical EW into the backup stop decision.** Should we introduce the special error code for the EW, e.g. "misappropriate" Win32 ERROR_DISK_FULL -- and a dedicated bool out flag? This will require updating the legacy path to deal with the new code / flag. *OR* should we just report EOM everywhere except when writing TOC streams?
-- **`TapeServiceBase`:** `Remaining` ⇒ `Drive.Remaining`; add `DriverReportedRemaining`,
- `EstimateMechanism` (`EarlyWarningMechanism`), and `IsEarlyWarning` passthroughs. Re-apply the configured
+- **`TapeServiceBase`:** `WritableRemaining` ⇒ `Drive.EstimatedContentRemaining` less the TOC reserve; add
+ `ReportedContentRemaining`, `EstimatedContentRemaining`, `EstimatedCapacity`, `RemainingEstimateMechanism`
+ and `IsEarlyWarning` passthroughs. Re-apply the configured
`EarlyWarning` reserve in `PrepareMedia` (fixes the "setter no-ops without media" gap).
- **Service calibration surface:** `CalibrateAsync(IProgress/callback, ref bool abort)`, `AddCalibration`,
`RemoveCalibration`, `LoadCalibration(stream)`, `SaveCalibration(cal, stream)`, and a
@@ -608,18 +612,17 @@ skipped as unconfigured). Key decisions, some diverging from the questions posed
`WriteResult` / `TapePackerEndOfMediaException` / the aligned stream / the agent would be pure churn for no
semantic gain. So **both write paths surface logical EW as EOM**, and the whole existing (heavily tested)
EOM machinery drives the wrap-up and TOC write unchanged.
-2. **New authoritative surface on `TapeDrive`.** Added `Remaining` ⇒ `EstimateActualRemaining()` (calibrated
- when available, raw driver value otherwise) and `DriverReportedRemaining` ⇒ `GetRemainingContentCapacity()`
- for diagnostics/UI "driver says vs. we estimate".
-3. **`AdjustRemainingContentCapacity` deprecated, not deleted.** Both the instance and static overloads (and the
- `TapeServiceBase` passthrough) are now `[Obsolete]` and internally route off `Drive.Remaining` instead of the
- raw driver figure. Retained as a backstop so the diagnostic `ContentCapacityLimit` test knob and any external
- callers keep compiling; the real stop signal is now EW.
+2. **New authoritative surface on `TapeDrive`.** Added `EstimatedContentRemaining` ⇒
+ `EstimateActualRemaining()` (calibrated when available, a-priori otherwise) and `ReportedContentRemaining`
+ ⇒ `GetReportedContentRemaining()` for the diagnostic/UI "reported / estimated" pairing.
+3. **`AdjustRemainingContentCapacity` retired.** It is replaced by the pure what-if helper
+ `TapeServiceBase.ComputeWritableRemaining(estimatedRemaining)`, which applies the same TOC-reserve rule as
+ the live `WritableRemaining` property; the real stop signal is EW.
4. **Reserve is armed per set in `TapeBackupAgent.BeginWriteContentForCurrentSet`.**
`Drive.SetEarlyWarning(HasInitiatorPartition ? 0 : Navigator.TOCCapacity)`. Because Phase 1/2 made
`SetEarlyWarning` always honored (matching calibration → a-priori fallback), `WriteDirect` reliably raises
`ew` ~one TOC-reserve before EOM regardless of whether a measured calibration is loaded.
- `ComputeRemainingCapacity` now returns `Drive.Remaining − (HasInitiatorPartition ? 0 : TOCCapacity)`, clamped
+ `ComputeRemainingCapacity` now returns `Drive.EstimatedContentRemaining − (HasInitiatorPartition ? 0 : TOCCapacity)`, clamped
≥ 0, and is only a backstop for the legacy `CapacityForCurrentSet` / `ContentCapacityLimit` checks.
5. **Aligned path:** `TapeWriteStream.WriteDirect` now reads the `ew` out-param and sets `EOFEncountered` when
`TapeStreamManager.ShouldStopContentOnEarlyWarning` is true — i.e. state is `WritingContent` **and** there is
@@ -627,29 +630,28 @@ skipped as unconfigured). Key decisions, some diverging from the questions posed
the write. The old capacity "fit" pre-check in `ProduceWriteContentStream` is left in place as a harmless
backstop rather than removed, to avoid disturbing its dedicated tests.
6. **Packed path:** `PackerWriteSink` reads the `ew` flag from `WriteDirect` and maps EW→EOM only when the TOC
- is co-located (`!Drive.HasInitiatorPartition`), dropping the now-obsolete `AdjustRemainingContentCapacity`
- call while preserving the `CapacityForCurrentSet` / `ContentCapacityLimit` reserve arithmetic as a backstop.
-7. **`TapeServiceBase`:** `Remaining` ⇒ `Drive.Remaining` (minus TOC reserve for TOC-in-set); added
- `DriverReportedRemaining`, `EstimateMechanism` (`EarlyWarningMechanism`), and `IsEarlyWarning` passthroughs.
+ is co-located (`!Drive.HasInitiatorPartition`), so the packer's existing rollback/EOM continuation handles
+ the wrap-up with no capacity pre-check of its own.
+7. **`TapeServiceBase`:** `WritableRemaining` ⇒ `Drive.EstimatedContentRemaining` (minus the TOC reserve for
+ TOC-in-set); added `ReportedContentRemaining`, `EstimatedContentRemaining`, `EstimatedCapacity`,
+ `RemainingEstimateMechanism` and `IsEarlyWarning` passthroughs.
No service-level `EarlyWarning` **setter** exists (the reserve is an agent-per-set concern), so the proposed
- "re-apply reserve in `PrepareMedia`" step was unnecessary and skipped. The Service calibration surface
- (`CalibrateAsync`, store, import/export) remains Phase 4+ work.
+ "re-apply reserve in `PrepareMedia`" step was unnecessary and skipped.
### Phase 4 — `TapeWinNET` (WPF) reporting + persistence
- **Media-usage reporting:** `MediaUsageBarPresenter` / `BackupMediaUsageBarPresenter` consume
- `Service.Remaining` (calibrated) instead of `AdjustRemainingContentCapacity`. In the `MainWindow` Properties
- ListView display in addition the "driver estimate" figure (`DriverReportedRemaining`).
- Finally, display the `EarlyWarningMechanism` so the user can see *why* the numbers differ.
+ `Service.WritableRemaining` / `ComputeWritableRemaining(...)` (calibrated). The `MainWindow` Properties
+ ListView shows the paired `reported / estimated` rows plus `Writable` and `Estimation by` (see Part 5.5),
+ so the user can see *why* the numbers differ.
Reproduce the same reporting in `TapeServiceBase.List.cs` (used by TapeConNET): `LogDriveInfo()` and `LogMediaInfoFull()`.
- **Log pane:** when a backup finalizes on logical EW, emit a `LogEntry` (at the `WarningLevel.Info`) —
e.g. *"Early warning: volume full at ~N GB (calibrated); writing table of contents."* — via the existing
`LogMessageReceived` → `AddLog` path, so the user understands why the run wrapped up before the driver's
optimistic figure.
-- **`MediaUsageBarPresenter`** (and derivates) also need an update since they employ the obsolete
- `AdjustRemainingContentCapacity()`.
- **Calibration persistence:** store `TapeCalibration` blobs via `TapeCalibrationStore` accessible via
- `AppSettings.Calibrations` API (already used in Phase 1A).
+ `AppSettings.Calibrations` API (already used in Phase 1A), and auto-apply matching profiles on drive open
+ and media load via `TapeServiceBase.AutoLoadCalibrations()`.
**Acceptance:** backup UI shows the calibrated figure; log pane explains the EW wrap-up; calibration profiles
persist across app restarts and auto-apply to matching media.
@@ -683,7 +685,7 @@ UI:
- **Result:** The summary output from service layer to the log pane (similar to Backup / Restore summary). To visualize
the result, let's add `CalibrationWindow` that shows measured `CapacityActual`, EW landmark, and `EwToEomDistance`;
offers *Save Profile* (into the `CalibrationStore`) and immediate *Apply Profile* via `AddCalibration`.
- - Bonus feature: How about also displaying a simple 2D graph to visualize Reported -> Actual remaining capacity
+ - Bonus feature: Displaying a simple 2D graph to visualize Reported -> Actual remaining capacity
curve, with the EW and EOM points marked? We already employ a simple 2D graph for `IoRateSparklineControl` ->
can resue much of its code; even lift to a common base class if this will simpolify the two implementations.
It'll be more intuitive to flip the X-axis (Remaining): Full capacity on the left, down to EOM on the right.
@@ -696,9 +698,64 @@ UI:
**Acceptance:** user can run, monitor, abort, and save a calibration entirely from the GUI; a saved profile
immediately improves the remaining-capacity figure for matching media.
+**The PR created by GitHub Copilot**: a service-layer calibration operation in `TapeLibNET.Services` and a GUI workflow in `TapeWinNET` to run, monitor, abort, review, save, and apply a calibration profile. It builds on the shipped Phases 0–4 without touching the validated low-level SCSI direct-write path.
+
+- **Service operation: calibration**
+ - Adds `CalibrateRequest` / `CalibrateResult` to the existing `ServiceOperationRequest -> operation -> ServiceOperationResult` pattern.
+ - Adds `ExecuteCalibrateAsync()` / `ExecuteCalibrateCore()` in `TapeServiceBase.EW.cs`.
+ - Introduces `ServiceCalibrateProgressHandler` to bridge calibration’s chunk-oriented progress into the existing operation-progress model used by the WPF overlay.
+ - Reuses the established cooperative abort flow by wiring service cancellation into `TapeCalibrator.IsAbortRequested`.
+ - Exposes minimal calibration-facing service surface needed by the UI (`DriveProfileKey`, active calibration, `AddCalibration()`).
+
+- **WPF workflow: confirm -> run -> review**
+ - Adds `CalibrateWindow` as the destructive-operation gate, patterned after the existing dialog conventions.
+ - Adds `CalibrationViewModel` to own the run lifecycle, abort coordination, save/apply actions, and result state.
+ - Adds `CalibrationWindow` to review the measured capacity, EW landmark, and EW→EOM distance, then save/apply the profile immediately.
+
+- **MainWindow progress integration**
+ - Extends the shared operation overlay to handle calibration alongside backup/restore instead of introducing a new progress surface.
+ - Adds `WpfServiceHost.UpdateCalibrateProgress()` and calibration-specific `MainViewModel` state/commands.
+ - Reuses the existing IO sparkline, percent bar, phase text, and abort button plumbing.
+
+- **Calibration curve visualization**
+ - Adds `CalibrationCurveControl` to plot `ReportedRemaining -> ActualRemaining`.
+ - Marks EW and EOM explicitly.
+ - Uses a split X-axis to magnify the EW→EOM tail region while keeping the full-capacity shape visible:
+ - pre-EW span uses most of the width
+ - EW→EOM tail gets a dedicated magnified segment
+
+- **As-built notes**
+ - Calibration does not use `TapeFileAgent` or TOC state, so it does not literally reuse `ServiceOperationProgressHandler`; instead it follows the same operation triad with a dedicated calibration progress adapter.
+ - `IoRateSparklineControl` is a rolling throughput sparkline, not a reusable 2D plot base, so the calibration graph is implemented as a dedicated control rather than forcing a shared inheritance layer.
+
+Example of the new service-layer shape:
+
+```csharp
+var result = await tapeService.ExecuteCalibrateAsync(
+ new CalibrateRequest(
+ EjectWhenDone: false,
+ Options: new TapeCalibrationOptions())
+ {
+ Cancellation = cancellationToken,
+ OperationLabel = "Calibration",
+ });
+
+if (result.Calibration is { } calibration)
+{
+ App.Settings.Calibrations.Save(calibration);
+ tapeService.AddCalibration(calibration);
+}
+```
+
+- **Fixes:**
+
+The calibration chart keeps its plot/axis labels inside the GroupBox content area, and a calibration run
+records both over-report anchors of a virtual-media run — `ReportedCapacityAtBom` and `PhantomFreeAtEom` —
+with regression coverage over the non-zero cases of each.
+
### Phase 6 — `TapeConNET` (CLI)
-- **Reporting:** the calibrated `Remaining` flows automatically through the Service layer; ensure any status
+- **Reporting:** the calibrated `WritableRemaining` flows automatically through the Service layer; ensure any status
output prints the estimate (and optionally `--verbose` shows driver-reported vs. calibrated + mechanism).
- **Calibrate command:** `tapecon --calibrate [--force]` runs a destructive calibration with a text progress
line and Ctrl-C ⇒ cooperative abort; on success saves the profile to the shared `CalibrationStore`.
@@ -715,3 +772,194 @@ landmark earlier than the fixed physical EW — converting the imprecise before-
byte-counted regime — remains future work, confined to the `TapeDrive` / `TapeCalibration` layer. The model
already reserves a nullable PEW curve (`LogicalPew → PewToSet`) and the `pew` write flag for it; no API changes
above are required to add it later.
+
+---
+
+## Part 5 — Capacity, remaining & early warning: the semantics [DONE]
+
+This part is the **normative glossary and contract** for everything the subsystem reports. Capacity and
+remaining are not one number but a small family of genuinely different quantities, each with its own source
+of truth; every API name, log line and UI label in the solution is derived from the vocabulary below.
+
+### 5.1 Semantic map — the canonical vocabulary
+
+| # | Quantity | Definition | Owner / source of truth |
+|---|----------|------------|--------------------------|
+| 1 | **True capacity** (`CapacityActual`) | Bytes that physically fit on the content partition, BOT → hard EOM. Ground truth. | Cartridge; measured by `TapeCalibrator`; emulated by `VirtualTapeMedia.m_capacity` |
+| 2 | **True remaining** | `TrueCapacity − trueWritten`. Reaches 0 exactly when hard EOM fires. | Only knowable on a virtual medium (`VirtualTapeMedia.TrueRemaining`) or after calibration |
+| 3 | **Driver-reported remaining** | What the drive/driver claims is still free. Optimistic, non-linear, floors above zero. | `TapeDriveBackend.Remaining` → `TapeDrive.GetReportedRemaining()` / `GetReportedContentRemaining()` |
+| 4 | **Driver-reported capacity at BOM** (`ReportedCapacityAtBom`) | Value of (3) sampled at beginning of media — the drive's own idea of cartridge size. May exceed (1). | `TapeCalibrator`, first sample |
+| 5 | **Phantom free space at EOM** (`PhantomFreeAtEom`) | Value of (3) at the instant hard EOM fires — space the driver claims but that does not exist. LTO-4: ~28 GB. | `TapeCalibrator`, EOM sample; persisted on `ITapeCalibration` |
+| 6 | **Estimated (calibrated) remaining** | (3) translated through the calibration curve → best estimate of (2). | `TapeDrive.EstimateActualRemaining()` / `EstimatedContentRemaining` |
+| 7 | **Physical EW** | Drive-asserted landmark, a fixed physical distance before hard EOM. Data *is* written. | Backend `ew` out-flag; distance recorded as `EwToEomDistance` |
+| 8 | **Logical EW reserve** | Caller's request: "tell me when only N bytes of *true* capacity remain", N = TOC size. Means *stop content, write TOC* — **not** "EOM". | `TapeDrive.EarlyWarning` (setter), `IsEarlyWarning` (sticky) |
+| 9 | **Writable-for-content remaining** | (6) minus the TOC reserve when the TOC shares the content partition. The number a backup planner may spend, and the headline UI figure. | `TapeServiceBase.WritableRemaining` |
+| 10 | **Overreport emulation** | The virtual medium's *deliberate* divergence of (3) from (2), so (6) has something to correct. | `VirtualTapeEwProfile.Anchors` |
+
+**The two independent over-report axes.** A driver can over-report in two entirely different ways, and both
+are modelled, measured and emulated as first-class, independent quantities — they are the two endpoints of
+the actual→reported line, i.e. the first and last points of the curve calibration builds:
+
+- **(a) Inflated capacity at BOM** — the driver claims 550 MB free on a 500 MB cartridge at beginning of
+ media and then counts down 1:1. The overshoot is a *constant* 50 MB from the very first byte. Carried by
+ `ReportedCapacityAtBom` (quantity 4) and emulated by `ReportedRemainingAnchors.ReportedCapacityBoost`.
+- **(b) Phantom free space at EOM** — the driver claims a truthful 500 MB at BOM but *decrements too
+ slowly*, so the overshoot grows from 0 to 50 MB at hard EOM. This is the faithful model of real LTO
+ behavior. Carried by `PhantomFreeAtEom` (quantity 5) and emulated by
+ `ReportedRemainingAnchors.PhantomFreeAtEom`.
+
+Limited practical testing suggests (a) ≈ 0 on real LTO-4 hardware, though this is not yet thoroughly
+measured and may prove significant on other generations; it can be emulated freely on virtual drives
+regardless. **Defaults: the a-priori calibration assumes (a) = 0, and virtual-drive emulation defaults
+(a) = 0** while defaulting (b) to the LTO-4-like 4 %.
+
+**The economic value-add of calibration, for TOC-in-set.** Where content stops depends entirely on what is
+known about the tail:
+
+- *No calibration:* content stops at the **physical EW** if the drive has one — leaving the whole, unknown
+ EW→EOM stretch unused: safe but wasteful — otherwise at the **a-priori** logical EW.
+- *With calibration:* content deliberately continues **past** the physical EW, byte-counting down the
+ measured `EwToEomDistance`, and stops when exactly the TOC reserve remains.
+
+This is the entire justification of the calibration feature, and it is stated in the class documentation of
+`TapeDrive`, `TapeCalibration` and `TapeFileBackupAgent`.
+
+### 5.2 Emulation — two explicit anchors
+
+```csharp
+/// The two endpoints of the emulated driver's actual→reported line. Independent axes:
+/// reported(0) = TrueCapacity + ReportedCapacityBoost // (a) inflated capacity at BOM
+/// reported(TrueCapacity) = PhantomFreeAtEom // (b) phantom free at hard EOM
+/// reported() interpolates linearly (monotonic non-increasing) between them.
+public readonly record struct ReportedRemainingAnchors(long ReportedCapacityBoost, long PhantomFreeAtEom);
+```
+
+`VirtualTapeEwProfile.Lto4Like(capacity, ewZonePercent, phantomFreePercent, reportedBoostPercent = 0)`
+builds the anchors; the boost defaults to 0, matching both the observed LTO-4 shape and the a-priori
+calibration's assumption. `VirtualTapeMedia`'s occupancy counter (incremented on write, decremented on
+truncate) drives the model, so `TrueRemaining` answers "how full is the cartridge" — correct for the
+append-only usage the medium is designed for.
+
+The Open Virtual Drive dialog exposes both axes with the shared %/MB/GB unit selector and a byte read-out:
+**Phantom free at EOM** (default 4 %) and **Capacity overreport (BOM)** (default 0, listed last because it
+is usually left alone).
+
+`ITapeCalibration` is deliberately used in **two opposite directions**, and both are documented as such: as
+an *estimation* artifact (`TranslateRemaining`: reported → actual, at runtime) and as an *emulation* source
+(`VirtualTapeEwProfile.FromCalibration` / `TranslateActualToReported`: actual → reported, for replaying a
+measured drive on a virtual one).
+
+### 5.3 Calibration artifact — one field per quantity
+
+| Field | Meaning |
+|-------|---------|
+| `CapacityActual` | quantity (1), measured bytes written to hard EOM |
+| `ReportedCapacityAtBom` | quantity (4), the driver's claim at beginning of media |
+| `PhantomFreeAtEom` | quantity (5), reported remaining at the instant hard EOM fires |
+| `ReportedCapacityTotal` (derived) | `CapacityActual + PhantomFreeAtEom` — the total capacity implied by the driver's own arithmetic |
+| `EwToEomDistance` | quantity (7), the measured physical-EW → hard-EOM stretch |
+| `Curve` | the sampled reported→actual pairs between the two anchors |
+
+The persisted DTO carries `FormatId = "tapelibnet-cal/2"`; profiles written by any other format are not
+loaded. `TapeCalibrator` samples `GetReportedContentRemaining()` so the curve and the diagnostic display
+always share one axis, and records the BOM and EOM anchors explicitly rather than inferring them from the
+curve endpoints.
+
+**Profile identity.** A calibration is keyed by `vendor|product|revision|cap=`.
+`TapeCalibration.CapacityBucket()` renders MB granularity below 2 GB and GB granularity above, so a 500 MB
+and a 900 MB cartridge never collide (`cap=500MB`). `VirtualTapeDriveBackend.Revision` is a **stable
+emulation identity** (`"v1"`) rather than the assembly version, so a saved virtual profile survives every
+build.
+
+**Autoload.** `TapeServiceBase.AutoLoadCalibrations()` feeds every profile from the shared
+`TapeCalibrationStore` (`%LocalAppData%\TapeLibNET\Calibrations`) to the drive on drive open **and** on
+media load — the profile key depends on the medium's capacity bucket, so it must be re-matched per medium.
+`TapeDrive` silently keeps the non-matching profiles for later media. The path is non-throwing: an
+unreadable store simply leaves the drive on the a-priori estimate. A measured profile is worthless if the
+user has to remember to apply it.
+
+### 5.4 The remaining-capacity API — one naming rule
+
+**`Reported*` is the raw driver figure; `Estimated*` is the calibrated one; `Writable*` has the TOC
+reserve deducted.**
+
+| Member | Quantity |
+|--------|----------|
+| `TapeDrive.GetReportedRemaining()` | (3), raw, current partition |
+| `TapeDrive.GetReportedContentRemaining()` / `ReportedContentRemaining` | (3), raw, content partition |
+| `TapeDrive.EstimateActualRemaining()` / `EstimatedContentRemaining` | (6) |
+| `TapeServiceBase.ReportedContentRemaining` | (3) |
+| `TapeServiceBase.EstimatedContentRemaining`, `EstimatedCapacity` | (6) and its capacity counterpart |
+| `TapeServiceBase.WritableRemaining` | (9) |
+| `TapeServiceBase.ComputeWritableRemaining(long estimatedRemaining)` | pure what-if form of (9) |
+
+`ComputeWritableRemaining` applies exactly the same TOC-reserve rule as the live `WritableRemaining`
+property to a *hypothetical* estimated remaining; `MediaUsageBarPresenter` and
+`BackupMediaUsageBarPresenter` use it for their "free space if these sets were added/removed" projections,
+so the what-if bar and the live figure can never disagree.
+
+`EarlyWarningMechanism` is the composed, display-oriented value covering both roles — how the estimate is
+derived (`Uncalibrated`, `Calibrated`) and how EW trips (`HardwareEarlyWarning`,
+`ProgrammableEarlyWarning`) — and drives `RemainingAndEwStatus` and the *Estimation by* row.
+`TapeDrive.EarlyWarning` is the byte reserve, `IsEarlyWarning` the sticky "reserve was crossed" flag, and
+`SetEarlyWarning(0)` additionally asks the backend to report its physical EW.
+
+### 5.5 UI — writable-first, with reported and estimated always paired
+
+`Writable` is the number the user actually cares about, so it is the most prominent figure everywhere.
+Drive and media property panes both show (illustrative figures):
+
+```
+Capacity reported / estimated : 780 GB / 780 GB
+Remaining reported / estimated : 612 GB / 603 GB
+Writable : 597 GB
+Estimation by : Calibration [+ "— early warning reached"]
+```
+
+- Paired rows share a single `reported / estimated` value cell, so the over-report gap is visible at a
+ glance without a separate "overreport" row.
+- *Estimation by* renders `none | apriori | Hardware | Calibration `, plus the
+ early-warning-reached marker, from the same mechanism-text logic as `RemainingAndEwStatus`.
+
+Status bar (2nd field):
+
+```
+Writable 597 GB of 780 GB
+```
+
+— the denominator is the **estimated** capacity; the fill-to-EOM / fill-to-EW and mechanism detail live in
+the property pane's *Estimation by* row and in the status-bar tooltip.
+
+The calibration result window leads with the two anchors: `PhantomFreeAtEom` as "your drive over-reports by
+X at EOM" and `ReportedCapacityAtBom` as "claims Y at BOM", alongside the measured capacity, the EW landmark
+and the EW→EOM distance.
+
+### 5.6 Test coverage
+
+All on a virtual drive, in `TapeLibNET.Tests`:
+
+1. `ReportedRemaining_AtBom_ReflectsCapacityBoost` — with `reportedBoostPercent: 10` on a 500 MB cartridge,
+ `backend.Remaining ≈ 550 MB` at BOM; with boost 0 it is exactly 500 MB. Pins the (a)/(b) split.
+2. `ReportedRemaining_AtHardEom_EqualsPhantomFree` — write to hard EOM with `phantomFreePercent: 10`;
+ `backend.Remaining ≈ 50 MB` while `TrueRemaining == 0`.
+3. `Calibration_MeasuresTrueCapacity` — `CapacityActual ∈ [0.99, 1.0] × trueCapacity`, asserted
+ *independent* of the over-report knobs (parameterized over boost/phantom ∈ {0, 10 %}).
+4. `Calibration_RecordsPhantomFreeAtEom` — `PhantomFreeAtEom ≈ 10 % × capacity ± 1 chunk`; with both knobs
+ at 0, `PhantomFreeAtEom ≈ 0`.
+5. `Calibration_RecordsReportedCapacityAtBom` — `ReportedCapacityAtBom ≈ capacity × (1 + boost)`.
+6. `EstimateActualRemaining_CorrectsInflatedReport` — after loading the calibration, at ≥ 5 sample points
+ `|estimate − trueRemaining| ≤ 2 % × capacity`, while `|reported − trueRemaining|` *exceeds* that bound at
+ the tail: the estimate is provably better than the raw report, not merely equal to it.
+7. `ProfileKey_IsStableAcrossReopen_AndDistinguishesCapacities` — the key is byte-identical after
+ close/reopen/version change, and differs between 500 MB and 900 MB cartridges.
+8. `StoredCalibration_IsAutoLoaded_OnDriveOpen` — save a profile to a temp store, open a matching virtual
+ drive through `TapeServiceBase`, assert `Calibration is not null` and a `Calibrated` mechanism without
+ any explicit `AddCalibration` call.
+9. `TocInSet_WithCalibration_WritesPastPhysicalEw` — the value-add test: with calibration loaded and a TOC
+ reserve of N bytes, the content phase stops with true remaining ≈ N, *past* the physical EW; without
+ calibration it stops at the physical EW. Guards the economics of the whole feature.
+10. `ReportedVsEstimatedRemaining_AreDistinct_UnderOverreport` — the service layer exposes both figures and
+ their difference ≈ the emulated over-report.
+
+Calibration JSON round-trips through `FormatId = "tapelibnet-cal/2"` and rejects unknown formats.
+
diff --git a/docs/TapeNET-Context-Primer.md b/docs/TapeNET-Context-Primer.md
index dcf0ff3..fe9ac97 100644
--- a/docs/TapeNET-Context-Primer.md
+++ b/docs/TapeNET-Context-Primer.md
@@ -215,6 +215,12 @@ Multiple files can share a single tape block, eliminating per-file alignment was
- **`TapeStreamManager` integration** — owns packer/backend lifecycle: `EnsurePackerCreated()` / `EnsureReadPackerCreated()` construct the respective stacks; `FlushAndDisposePacker()` / `DisposeReadPacker()` tear them down. `BeginPackedFile()` / `EndPackedFile()` and `BeginPackedFileRead()` / `EndPackedFileRead()` are the agent-facing entry points. `Manager.FilesCommitted` re-exposes the inner packer's event. The read-packer field is typed `ITapeFileReader?` so the pipelined reader and any future implementation are interchangeable without touching call sites.
- **Agent integration** — `TapeFileBackupAgent` packed methods sit alongside the legacy aligned methods (retained with `[Obsolete]` for TOC I/O and side-by-side testing). `TapeFileRestoreBaseAgent` restore agents (`TapeFileRestoreAgent`, `TapeFileValidateAgent`, `TapeFileVerifyAgent`) operate unchanged against the generic `Stream` returned by `BeginPackedFileRead` — the pipelined prefetch is fully transparent. The legacy `[Obsolete]` aligned restore methods (`RestoreNextFileAligned`, etc.) are retained in source for TOC-path testing; `TapeFileReadPacker` and `SyncTapeReadBackend` have been removed from the active build and preserved under `TapeLibNET/Excluded Files/`. Cross-path compatibility: legacy (aligned) backup output restores correctly through the packed path; packed backup output requires the packed restore path.
+### Remaining capacity & early warning
+
+Complete design specification: `docs/Design-RemainingAndEw.md`.
+
+A tape drive's own "space remaining" figure is optimistic — an LTO-4 still claims ~28 GB free at the instant it hits hard end-of-medium — so TapeLibNET treats capacity as a small family of distinct quantities rather than one number: the raw **reported** figure from the driver, the **estimated** figure obtained by translating it through a per-drive+media calibration, and the **writable** figure the user actually spends (estimated, less the table-of-contents reserve when the TOC shares the content partition). Two independent over-report axes are measured, persisted and emulated: an inflated capacity claim at beginning of media (`ReportedCapacityAtBom`) and phantom free space still claimed at hard EOM (`PhantomFreeAtEom`). `TapeCalibrator` measures them destructively once per drive+media profile; profiles persist in a shared store and **auto-apply on drive open and media load**, so the user never has to remember to arm them. `TapeDrive.EarlyWarning` turns all this into the one signal that matters — "stop content now, there is exactly room for the TOC" — mapping the drive's physical early-warning landmark and the measured EW→EOM distance onto the caller's byte reserve. **What it brings:** a trustworthy figure instead of a guess, and a cartridge filled to its real end — without calibration a backup must stop at the drive's physical early warning and abandon the entire unknown tail; with calibration it deliberately writes *past* that landmark, byte-counting down the measured distance, and stops with precisely the TOC reserve left. The UI is writable-first throughout: property panes pair `reported / estimated` on one row with `Writable` and an `Estimation by` row beneath, the status bar reads `Writable X of Y`, the calibration result window leads with "your drive over-reports by X at EOM", and virtual drives can emulate either over-report axis so the whole chain is exercisable without hardware.
+
### Win32 BackupRead / BackupWrite file I/O (`TapeBackupStream`)
Complete design specification: `docs/Design-BackupRead-BackupWrite.md`.
From e58df14752713ca84cc5fd863fccab74186a9f5f Mon Sep 17 00:00:00 2001
From: Alex K
Date: Fri, 7 Aug 2026 23:23:04 +0200
Subject: [PATCH 06/37] Set EW mechanism as the only source of truth to reserve
TOC space while writing content.
---
.../CalibrationAndLogicalEwTests.cs | 13 +-
.../Helpers/VirtualTapeFixture.cs | 2 +-
.../Remote/RemoteServiceMultiVolumeTests.cs | 13 +-
.../Services/ServiceMultiVolumeTests.cs | 11 +-
TapeLibNET/Services/TapeServiceBase.EW.cs | 19 ++-
TapeLibNET/TapeBackupAgent.cs | 13 +-
TapeLibNET/TapeDrive.cs | 126 ++++++++++++++++--
TapeLibNET/TapeStreamManager.cs | 41 +++---
TapeLibNET/Virtual/VirtualTapeDriveBackend.cs | 5 +
9 files changed, 184 insertions(+), 59 deletions(-)
diff --git a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
index 57c62c8..f59b64b 100644
--- a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
+++ b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
@@ -333,17 +333,22 @@ public void LogicalEw_AfterPhysicalEw_FiresFromByteCountWithSmallReserve()
while (true)
{
int n = drive.WriteDirect(data, 0, block, out _, out bool ew, out bool eom);
- if (eom || n == 0)
- break;
- // Track whether the physical EW was observed before logical EW fired.
- physicalSeen |= drive.EstimateActualRemaining() < drive.GetReportedContentRemaining();
+ // Check the EW flags BEFORE the loop-exit guard: a write clamped down to zero bytes to
+ // preserve the reserve still reports ew, and would otherwise be swallowed by n == 0.
+ // IsPhysicalEarlyWarningSeen is the drive's actual physical landmark -- unlike comparing
+ // the estimate to the reported value, which with an a-priori calibration is ALWAYS true
+ // (the curve models actual ˜ reported - margin) and so detects nothing.
+ physicalSeen |= drive.IsPhysicalEarlyWarningSeen;
if (ew)
{
sawPhysicalEwBeforeLogical = physicalSeen;
break;
}
+
+ if (eom || n == 0)
+ break;
}
Assert.True(drive.IsEarlyWarning, "Logical EW should have fired near the tail");
diff --git a/TapeLibNET.Tests/Helpers/VirtualTapeFixture.cs b/TapeLibNET.Tests/Helpers/VirtualTapeFixture.cs
index 1469b32..49e5254 100644
--- a/TapeLibNET.Tests/Helpers/VirtualTapeFixture.cs
+++ b/TapeLibNET.Tests/Helpers/VirtualTapeFixture.cs
@@ -113,7 +113,7 @@ public VirtualTapeFixture(
DriveProfile.Setmarks => VirtualTapeDriveCapabilities.WithSetmarks,
DriveProfile.Partitions => VirtualTapeDriveCapabilities.WithPartitions,
DriveProfile.SeqFilemarks => VirtualTapeDriveCapabilities.WithSeqFilemarks,
- DriveProfile.FilemarksOnly => VirtualTapeDriveCapabilities.WithFilemarksOnlyLargeBlocks,
+ DriveProfile.FilemarksOnly => VirtualTapeDriveCapabilities.WithFilemarksOnly, // WithFilemarksOnlyLargeBlocks,
_ => throw new ArgumentOutOfRangeException(nameof(profile)),
};
diff --git a/TapeLibNET.Tests/Services/Remote/RemoteServiceMultiVolumeTests.cs b/TapeLibNET.Tests/Services/Remote/RemoteServiceMultiVolumeTests.cs
index 1166191..eb2f484 100644
--- a/TapeLibNET.Tests/Services/Remote/RemoteServiceMultiVolumeTests.cs
+++ b/TapeLibNET.Tests/Services/Remote/RemoteServiceMultiVolumeTests.cs
@@ -35,8 +35,9 @@ public class RemoteServiceMultiVolumeTests(LocalHostTapeServiceFixture fixture)
///
/// Content-partition capacity for setmarks multi-volume test volumes (36 MiB).
/// TOC reserve is 32 MiB → 4 MiB usable; total content ~5.6 MiB overflows trivially.
+ /// +5% capacity (1.8 MiB) for EW estimation margin → 38 MiB
///
- private const long MultiVolumeCapacity_Setmarks = 36L * 1024 * 1024;
+ private const long MultiVolumeCapacity_Setmarks = 38L * 1024 * 1024;
///
/// Content-partition capacity for initiator-partition multi-volume test volumes (3 MiB).
@@ -49,7 +50,9 @@ public class RemoteServiceMultiVolumeTests(LocalHostTapeServiceFixture fixture)
private const int MvIncFileCount = 16;
private const long MvIncFileSizeFull = 350L * 1024;
private const long MvIncFileSizeModified = 700L * 1024;
- private const long MvIncVol1Capacity_Setmarks = 40L * 1024 * 1024;
+ private const long MvIncVol1Capacity_Setmarks = 41L * 1024 * 1024;
+ // Added 1 MiB so that at least one file from the follow-up set fits, therefore splitting
+ // the set across vol-1 and vol-2 -- as we check volume continuation in the test.
private const long MvIncVol1Capacity_Initiator = 8L * 1024 * 1024;
private const long MvIncVol2Capacity_Setmarks = 46L * 1024 * 1024;
private const long MvIncVol2Capacity_Initiator = 14L * 1024 * 1024;
@@ -81,7 +84,8 @@ public async Task Remote_MultiVolume_RegularBackup_SpansVolumes_RestoreAllFiles(
long volumeCapacity = withInitiator ? MultiVolumeCapacity_Initiator : MultiVolumeCapacity_Setmarks;
using var vol1 = new TempVirtualMedia(withInitiator, volumeCapacity);
using var vol2 = new TempVirtualMedia(withInitiator, volumeCapacity);
- IReadOnlyList volumes = [vol1, vol2];
+ //using var vol3 = new TempVirtualMedia(withInitiator, volumeCapacity);
+ IReadOnlyList volumes = [vol1, vol2,/*vol3,*/ ];
using var src = new TempFileTree();
AddMultiVolumeContent(src);
@@ -361,9 +365,10 @@ await svc2.OpenRemoteVirtualFileAsync(
/// Capacity used for the catalog-driven test volumes: matches
/// (36 MiB) so that the setmarks TOC overhead (32 MiB) leaves 4 MiB of usable data space, forcing
/// at least one volume swap when writing 16 × 350 KiB files (~5.6 MiB total).
+ /// +5% capacity (1.8 MiB) for EW estimation margin → 38 MiB
/// No initiator partition — CreateTempVirtual does not support one.
///
- private const long CatalogDrivenVolumeCapacity = 36L * 1024 * 1024;
+ private const long CatalogDrivenVolumeCapacity = 38L * 1024 * 1024;
// ── 8.10: catalog-driven multi-volume backup + restore ────────────────────
diff --git a/TapeLibNET.Tests/Services/ServiceMultiVolumeTests.cs b/TapeLibNET.Tests/Services/ServiceMultiVolumeTests.cs
index d267c75..5bc86e7 100644
--- a/TapeLibNET.Tests/Services/ServiceMultiVolumeTests.cs
+++ b/TapeLibNET.Tests/Services/ServiceMultiVolumeTests.cs
@@ -37,8 +37,9 @@ public class ServiceMultiVolumeTests : ServiceTestBase
/// Must be larger than (32 MiB) because
/// the backup agent reserves that space for the in-tape TOC on setmarks drives.
/// 36 MiB → 4 MiB usable per volume; 22 MiB total content overflows trivially.
+ /// +5% capacity (1.8 MiB) for EW estimation margin → 38 MiB
///
- private const long MultiVolumeCapacity_Setmarks = 36L * 1024 * 1024;
+ private const long MultiVolumeCapacity_Setmarks = 38L * 1024 * 1024;
///
/// Content-partition capacity for initiator-partition multi-volume test volumes.
@@ -73,8 +74,10 @@ public class ServiceMultiVolumeTests : ServiceTestBase
/// With file-header overhead this slightly exceeds a 6 MiB usable window, so
/// vol-1 is sized at 40 MiB (usable = 40 − 32 MiB TOC reserve = 8 MiB):
/// 8 MiB > ~6.1 MiB (full backup, block-padded) ✓ and 8 MiB < ~11.2 MiB (incremental) ✓.
+ /// Added 1 MiB so that at least one file from the follow-up set fits, therefore splitting
+ /// the set across vol-1 and vol-2 -- as we check volume continuation in the test.
///
- private const long MvIncVol1Capacity_Setmarks = 40L * 1024 * 1024;
+ private const long MvIncVol1Capacity_Setmarks = 41L * 1024 * 1024;
///
/// Vol-1 capacity for initiator-partition drives in A-6.
@@ -261,8 +264,8 @@ public async Task MultiVolume_RegularBackup_SpansVolumes_RestoreAllFiles(bool wi
/// Volume sizing: vol-1 capacity is chosen so the full backup (~5.5 MiB,
/// 16 × 350 KiB) fits with headroom to spare, while the incremental backup
/// (~11.2 MiB, 16 × 700 KiB) exceeds that headroom and spills onto vol-2.
- /// Profile-specific values: 24 MiB setmarks (16 MiB TOC reserve → 8 MiB
- /// usable) / 8 MiB initiator.
+ /// Profile-specific values: for setmarks s. explanation for
+ /// / 8 MiB initiator.
///
///
/// Three assertions are made after setup:
diff --git a/TapeLibNET/Services/TapeServiceBase.EW.cs b/TapeLibNET/Services/TapeServiceBase.EW.cs
index f4d2734..8ce2dde 100644
--- a/TapeLibNET/Services/TapeServiceBase.EW.cs
+++ b/TapeLibNET/Services/TapeServiceBase.EW.cs
@@ -1,6 +1,8 @@
using Windows.Win32.Foundation;
using Windows.Win32.System.SystemServices; // Helpers, Stopwatch
+using TapeLibNET.Virtual;
+
using Stopwatch = Windows.Win32.System.SystemServices.Stopwatch;
namespace TapeLibNET.Services;
@@ -217,17 +219,30 @@ public bool AddCalibration(ITapeCalibration calibration)
/// remember to apply it. The drive matches on and silently
/// keeps the non-matching ones for when other media is loaded.
///
+ /// For a , autoload only runs when EOM behavior emulation
+ /// () is actually active: a non-emulated
+ /// virtual drive is truthful by construction, so applying a calibration measured against real (or
+ /// differently emulated) hardware would misrepresent it. Physical and remote drives are unaffected.
+ ///
+ ///
/// Non-throwing and non-fatal: a store that cannot be read simply leaves the drive uncalibrated,
/// falling back to the a-priori estimate. Call after the drive is open AND media is loaded, since
/// the profile key includes the media capacity bucket.
///
///
- /// The number of profiles offered, or 0 when none were available.
+ /// The number of profiles offered, or 0 when none were available or autoload was skipped.
protected int AutoLoadCalibrations()
{
if (_drive is null)
return 0;
+ // Virtual drives only warrant autoload when EOM behavior emulation is switched on for them.
+ if (_drive.Backend is VirtualTapeDriveBackend { EmulatedEarlyWarning: not { EarlyWarningZone: > 0 } })
+ {
+ LogInfoSub("Calibration autoload skipped: EOM behavior emulation is not active for this virtual drive");
+ return 0;
+ }
+
try
{
var calibrations = CalibrationStore.LoadAll();
@@ -238,7 +253,7 @@ protected int AutoLoadCalibrations()
_drive.AddCalibration(cal);
if (_drive.Calibration is { } matched)
- LogInfoSub($"Calibration applied: {matched.ProfileKey}");
+ LogOkSub($"Calibration applied: {matched.ProfileKey}");
else
LogInfoSub($"Calibration: {calibrations.Count} profile(s) loaded, none matching " +
$"'{_drive.DriveProfileKey}' — using the a-priori estimate");
diff --git a/TapeLibNET/TapeBackupAgent.cs b/TapeLibNET/TapeBackupAgent.cs
index 12fd200..35437c6 100644
--- a/TapeLibNET/TapeBackupAgent.cs
+++ b/TapeLibNET/TapeBackupAgent.cs
@@ -78,13 +78,12 @@ protected override void Dispose(bool disposing)
private long ComputeRemainingCapacity()
{
// The authoritative remaining-capacity figure is the drive's calibrated ESTIMATE
- // (quantity (6)), from which we reserve room for the TOC when it is co-located with content
- // (no Initiator partition) — yielding the WRITABLE remaining, quantity (9). Early-warning
- // enforcement (see BeginWriteContentForCurrentSet) is the real stop signal; this value is
- // only a backstop for the capacity pre-checks.
- var remainingCapacity = Drive.EstimatedContentRemaining
- - (Drive.HasInitiatorPartition ? 0L : Navigator.TOCCapacity);
- return Math.Max(remainingCapacity, 0L);
+ // (quantity (6)). The TOC reserve is NOT subtracted here: it is armed once, at the drive,
+ // via SetEarlyWarning() in BeginWriteContentForCurrentSet(). Subtracting it again would
+ // reserve room for the TOC twice and prematurely cut the set short (see the multi-volume
+ // regression). Early warning is the real stop signal; this value only feeds the coarse
+ // pre-checks on the (obsolete) aligned write path.
+ return Math.Max(Drive.EstimatedContentRemaining, 0L);
}
private bool BeginWriteContentForCurrentSet(bool newSet)
diff --git a/TapeLibNET/TapeDrive.cs b/TapeLibNET/TapeDrive.cs
index 544e5c1..4a38b02 100644
--- a/TapeLibNET/TapeDrive.cs
+++ b/TapeLibNET/TapeDrive.cs
@@ -46,6 +46,10 @@ public class TapeDrive(ILoggerFactory loggerFactory, TapeDriveBackend backend)
private long m_ewAnchorBlock = -1L; // drive logical block where physical EW first fired
private long m_bytesAfterPhysicalEwCarry = 0L; // bytes-after-EW frozen across block-size changes
private long m_bytesSinceRemainingPoll = 0L; // paces the ReportedRemaining poll (approx ok)
+ // Writable headroom (estimate minus reserve) observed at the last poll. Paces the NEXT poll so the
+ // sampling rate tracks how little is actually left. long.MaxValue = not yet polled (first write
+ // after a reserve is set polls immediately to establish a real value).
+ private long m_writableHeadroomAtLastPoll = long.MaxValue;
// Calibrations loaded by the app (typically one per capacity bucket / media type). TapeDrive
// auto-selects the matching one into m_calibration. Not owned/persisted here.
@@ -68,9 +72,30 @@ public class TapeDrive(ILoggerFactory loggerFactory, TapeDriveBackend backend)
private const int c_gapFileLength = 64;
- // Throttle for the (device-querying) ReportedRemaining poll used by the pre-physical-EW logical
- // EW check. Only exercised when the desired reserve exceeds the physical EW→EOM distance.
- private const long c_ewRemainingPollInterval = 64L * 1024 * 1024; // 64 MB #endregion
+ // Upper bound for the (device-querying) ReportedRemaining poll used by the pre-physical-EW logical
+ // EW check. The effective interval is scaled down from this ceiling to a fraction of the WRITABLE
+ // headroom still ahead of the reserve (see RemainingPollInterval), so the check also works on
+ // drives WITHOUT a physical EW and on media whose headroom is smaller than the ceiling -- there
+ // the curve poll is the ONLY way logical EW can fire.
+ private const long c_ewRemainingPollIntervalMax = 64L * 1024 * 1024; // 64 MB
+
+ // The poll must be fine-grained relative to the headroom it is watching shrink -- NOT relative to
+ // the reserve, which may be arbitrarily large compared to what is actually left (e.g. a 32 MiB TOC
+ // reserve on a 36 MiB cartridge leaves ~2 MiB of headroom). Sampling every quarter of the headroom
+ // bounds the overshoot to ~25% of it, at a cost of at most 4 queries per headroom-width of tape.
+ // Floored at the block size so it can never degenerate to polling on every write.
+ // Uses the headroom CACHED at the last poll, so this stays a pure arithmetic property: it is read
+ // on every write and must never query the device itself.
+ private long RemainingPollInterval
+ {
+ get
+ {
+ // Guard the (pathological) case of a block size at/above the ceiling, which would
+ // make min > max and throw in Math.Clamp.
+ long floor = Math.Clamp(Math.Max(1L, BlockSize), 1L, c_ewRemainingPollIntervalMax);
+ return Math.Clamp(Math.Max(0L, m_writableHeadroomAtLastPoll) / 4L, floor, c_ewRemainingPollIntervalMax);
+ }
+ }
#endregion // *** Private Constants ***
@@ -216,7 +241,7 @@ public TimeSpan OperationTimeout
/// params). Optimistic on real hardware: it overshoots the truth and floors above zero at hard EOM.
/// Use for capacity decisions. Returns 0 on failure.
///
- public long GetReportedRemaining() => EnsureMediaParams()?.Remaining ?? 0L;
+ public long GetReportedCurrentPartitionRemaining() => EnsureMediaParams()?.Remaining ?? 0L;
///
/// Quantity (3) for the Content partition, cached from the last time media params were refreshed
@@ -227,7 +252,7 @@ public long GetReportedContentRemaining()
if (m_onContentPartition)
{
// On content — refresh to get the latest value and cache it
- return GetReportedRemaining();
+ return GetReportedCurrentPartitionRemaining();
}
// On another partition — return the cached content remaining
@@ -291,6 +316,11 @@ public long GetReportedContentRemaining()
///
internal bool IsProgrammableEarlyWarning { get; private set; } = false;
+ /// Sticky flag: the BACKEND's physical early warning has fired this pass (the landmark
+ /// anchoring the precise tail estimate). Distinct from the logical ,
+ /// which maps this plus the calibration onto the caller's requested reserve.
+ internal bool IsPhysicalEarlyWarningSeen => m_physicalEwSeen;
+
/// Running count of bytes transferred via /. Reset by the stream manager.
public long ByteCounter
{
@@ -375,6 +405,21 @@ public int WriteDirect(byte[] buffer, int offset, int count,
if (toWrite == 0)
return 0;
+ // Logical EW is evaluated AFTER the write, so on its own it can only ever report
+ // "you have already crossed the line". That is harmless while a single write is small
+ // relative to the reserve, but a write comparable to (or larger than) the reserve can
+ // consume the whole reserve -- and the room meant for the TOC -- in one go. Guard that
+ // case by clamping the write to what still fits ahead of the reserve.
+ bool ewClamped = ClampWriteToEarlyWarning(ref toWrite);
+ if (toWrite == 0)
+ {
+ // Nothing fits ahead of the reserve: report EW without writing so the caller
+ // wraps up the set (the packer rolls back its uncommitted tail).
+ ew = m_desiredEarlyWarning > 0L;
+ IsEarlyWarning = true;
+ return 0;
+ }
+
m_IoTimer.Restart();
int written = m_backend.Write(buffer, offset, toWrite,
out tapemark, out bool pew, out bool physicalEw, out eom);
@@ -402,7 +447,7 @@ public int WriteDirect(byte[] buffer, int offset, int count,
// With NO reserve requested this surfaces the drive's physical EW 1:1 (v1.0 behavior, and exactly
// what a calibration run needs to capture the EW landmark). The costly ReportedRemaining poll lives
// inside EvaluateLogicalEarlyWarning and is gated + throttled, so calling it per write is cheap.
- bool logicalEw = EvaluateLogicalEarlyWarning(written, physicalEw);
+ bool logicalEw = EvaluateLogicalEarlyWarning(written, physicalEw) || ewClamped;
if (logicalEw && !IsEarlyWarning)
m_logger.LogInformation("{Prefix}: WriteDirect crossed logical early-warning boundary", LogPrefix);
IsEarlyWarning = logicalEw;
@@ -484,6 +529,7 @@ internal void ResetEarlyWarningRuntime()
m_ewAnchorBlock = -1L;
m_bytesAfterPhysicalEwCarry = 0L;
m_bytesSinceRemainingPoll = 0L;
+ m_writableHeadroomAtLastPoll = long.MaxValue;
}
///
@@ -530,15 +576,18 @@ public bool SetEarlyWarning(long bytesBeforeEom)
}
///
- /// Selects the best available logical-EW mechanism given the requested reserve, loaded calibration,
- /// backend capabilities, and media capacity. Synthesizes an a-priori baseline when no measured
- /// calibration matches, so a reserve is always enforceable.
+ /// Selects the best available logical-EW mechanism given the loaded calibration, backend
+ /// capabilities, and media capacity. Synthesizes an a-priori baseline when no measured calibration
+ /// matches, so a reserve is always enforceable as soon as one is requested via
+ /// . Runs independently of whether a reserve has actually been
+ /// requested yet, so EarlyWarningMechanism (and the UI's "Estimation by") reflects a
+ /// matched/auto-loaded calibration immediately, not only once a reserve is later set.
///
private void SelectEarlyWarningMechanism()
{
m_aprioriCalibration = null;
- if (m_desiredEarlyWarning <= 0L || !IsMediaLoaded)
+ if (!IsMediaLoaded)
{
m_ewMechanism = EarlyWarningMechanism.None;
return;
@@ -600,16 +649,65 @@ private bool EvaluateLogicalEarlyWarning(int written, bool physicalEw)
}
// Before physical EW: consult the curve on ReportedRemaining, throttling the costly query,
- // while still honoring a physical-EW backstop between polls.
+ // while still honoring a physical-EW backstop between polls. On a drive with no physical EW
+ // this poll is the ONLY path that can raise logical EW, hence the reserve-relative interval.
m_bytesSinceRemainingPoll += written;
- if (m_bytesSinceRemainingPoll < c_ewRemainingPollInterval)
+ if (m_bytesSinceRemainingPoll < RemainingPollInterval)
return physicalEw;
m_bytesSinceRemainingPoll = 0L;
- long est = cal.TranslateRemaining(GetReportedRemaining());
+ long est = cal.TranslateRemaining(GetReportedContentRemaining());
+ m_writableHeadroomAtLastPoll = est - m_desiredEarlyWarning; // paces the next poll
return est <= m_desiredEarlyWarning || physicalEw;
}
+ ///
+ /// Clamps a pending write so it cannot overrun the requested early-warning reserve in a single
+ /// call. Logical EW is necessarily evaluated AFTER a write, so a write that is large relative to
+ /// the reserve could otherwise consume the reserve -- the room set aside for the TOC -- before
+ /// anyone gets a chance to react. This is the pre-write counterpart to
+ /// .
+ ///
+ /// Deliberately cheap and self-limiting: it only engages once the write is big enough to matter
+ /// relative to the headroom still ahead of the reserve, so on real drives -- where a 256 KiB write
+ /// sits against gigabytes of headroom -- it never fires and costs a single comparison. It becomes
+ /// active exactly in the coarse-granularity regime (tiny emulated media, a nearly-full cartridge,
+ /// or a genuinely huge write) where the post-hoc signal is too late to be useful.
+ ///
+ ///
+ /// Block-aligned byte count to write; reduced in place if it would overrun.
+ /// True if the write was clamped, i.e. the reserve boundary is being reached now.
+ private bool ClampWriteToEarlyWarning(ref int toWrite)
+ {
+ if (m_desiredEarlyWarning <= 0L || IsEarlyWarning)
+ return false;
+
+ // Only worth checking once a single write could eat a meaningful part of the headroom still
+ // ahead of the reserve. Gauged against the HEADROOM (not the reserve, which may dwarf what is
+ // actually left), using the value cached by the EW poll so this stays off the hot path: on a
+ // real drive a 256 KiB write against GB of headroom exits on one comparison.
+ if (m_writableHeadroomAtLastPoll != long.MaxValue
+ && toWrite < m_writableHeadroomAtLastPoll / 4L)
+ return false;
+
+ long writable = EstimateActualRemaining() - m_desiredEarlyWarning;
+ m_writableHeadroomAtLastPoll = writable; // refresh the pacing hint; we just paid for the query
+ if (writable >= toWrite)
+ return false;
+
+ uint blockSize = BlockSize;
+ long aligned = writable > 0L && blockSize > 0
+ ? writable - (writable % blockSize)
+ : 0L;
+
+ m_logger.LogInformation(
+ "{Prefix}: Clamping write of {ToWrite} B to {Aligned} B to preserve the {Reserve} B early-warning reserve",
+ LogPrefix, toWrite, aligned, m_desiredEarlyWarning);
+
+ toWrite = (int)Math.Min(aligned, toWrite);
+ return true;
+ }
+
///
/// Physical-tape bytes written since the built-in early warning fired, measured from the DRIVE's
/// authoritative logical block position (blocks × block size) and carried correctly across any
@@ -705,7 +803,7 @@ private void SelectCalibration()
///
public long EstimateActualRemaining()
{
- long reported = GetReportedRemaining();
+ long reported = GetReportedContentRemaining();
if (reported < 0L)
return 0L;
ITapeCalibration? cal = EffectiveCalibration;
diff --git a/TapeLibNET/TapeStreamManager.cs b/TapeLibNET/TapeStreamManager.cs
index d737e59..9534e69 100644
--- a/TapeLibNET/TapeStreamManager.cs
+++ b/TapeLibNET/TapeStreamManager.cs
@@ -46,9 +46,9 @@ public class TapeStreamManager : TapeDriveHolder
private TapeFileWritePacker? m_packer;
// Bytes already handed off to the drive by the packer in the current content
- // session. Used to enforce CapacityForCurrentSet (which reserves room for the
- // TOC when there's no Initiator partition) -- the aligned path enforces this in
- // ProduceWriteContentStream, and the packed path enforces it in PackerWriteSink.
+ // session. Used only to enforce the artificial ContentCapacityLimit in
+ // PackerWriteSink; the TOC reserve itself is enforced by the drive's logical
+ // early warning, armed once by the backup agent.
private long m_packerBytesWritten;
// Read-side packer: lazily constructed inside BeginPackedFileRead and torn down
@@ -571,7 +571,10 @@ private bool EndReadContentSet()
return WentOK;
}
- private long CapacityForCurrentSet { get; set; } = -1; // unknow
+ // Coarse per-set capacity estimate, used only by the legacy aligned write path's
+ // CheckContentCapacity() pre-check. The packed path relies on the drive's logical
+ // early warning instead, so this value is never enforced there.
+ private long CapacityForCurrentSet { get; set; } = -1; // unknown
///
/// Phase 3: true when a logical early warning should terminate the current content write and
@@ -798,24 +801,16 @@ private WriteResult PackerWriteSink(byte[] buffer, int validBytes)
{
try
{
- // Enforce CapacityForCurrentSet only when the TOC is co-located with content
- // (no Initiator partition) -- there we MUST leave room for the TOC at the end.
- // When an Initiator partition is present, the drive's real EOM is authoritative
- // and we let it surface through Drive.WriteDirect's eof flag below; clamping
- // here would needlessly roll back files that would otherwise have committed.
- // The artificial ContentCapacityLimit (test/diagnostic knob) is honored in either case.
- bool enforceReserved = CapacityForCurrentSet >= 0 && !Drive.HasInitiatorPartition;
- if (enforceReserved || ContentCapacityLimit > 0L)
+ // The drive's logical early warning is the single authoritative stop signal for
+ // content writing: it is armed by the backup agent with the TOC reserve and is
+ // evaluated by TapeDrive using the best available mechanism (calibration, physical
+ // EW, or a-priori estimate). Enforcing CapacityForCurrentSet here as well would
+ // duplicate that decision with a strictly more pessimistic (and compression-blind)
+ // byte count, cutting sets short. Only the artificial ContentCapacityLimit
+ // (test/diagnostic knob) is still clamped here.
+ if (ContentCapacityLimit > 0L)
{
- long remaining = long.MaxValue;
- if (enforceReserved)
- {
- remaining = CapacityForCurrentSet - m_packerBytesWritten;
- }
-
- // Honor the artificial ContentCapacityLimit if set
- if (ContentCapacityLimit > 0L)
- remaining = Math.Min(remaining, ContentCapacityLimit - m_packerBytesWritten);
+ long remaining = ContentCapacityLimit - m_packerBytesWritten;
if (validBytes > remaining)
{
@@ -828,8 +823,8 @@ private WriteResult PackerWriteSink(byte[] buffer, int validBytes)
? (int)(remaining - (remaining % blockSize))
: 0;
- m_logger.LogTrace("Drive #{Drive}: Remaining {Remaining} B; packer hit reserved capacity ({Written}+{Bytes} > {Cap}); writing {Writable} B then EOM",
- DriveNumber, remaining, m_packerBytesWritten, validBytes, CapacityForCurrentSet, writable);
+ m_logger.LogTrace("Drive #{Drive}: Remaining {Remaining} B; packer hit artificial content capacity limit ({Written}+{Bytes} > {Cap}); writing {Writable} B then EOM",
+ DriveNumber, remaining, m_packerBytesWritten, validBytes, ContentCapacityLimit, writable);
int partialBlocks = 0;
if (writable > 0)
diff --git a/TapeLibNET/Virtual/VirtualTapeDriveBackend.cs b/TapeLibNET/Virtual/VirtualTapeDriveBackend.cs
index 04d38e0..f9d8ff1 100644
--- a/TapeLibNET/Virtual/VirtualTapeDriveBackend.cs
+++ b/TapeLibNET/Virtual/VirtualTapeDriveBackend.cs
@@ -45,6 +45,11 @@ public readonly record struct VirtualTapeDriveCapabilities
SupportsSeqFilemarks = true,
};
+ /// A basic drive with filemarks support only.
+ public static VirtualTapeDriveCapabilities WithFilemarksOnly => Basic with
+ {
+ };
+
/// Simulates a filemarks-only drive (like LTO-1..4) — no setmarks, no sequential filemark counting.
public static VirtualTapeDriveCapabilities WithFilemarksOnlyLargeBlocks => new()
{
From 0056793bea41c37ff063f178475b6088624a5b2a Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Sat, 8 Aug 2026 03:51:00 +0200
Subject: [PATCH 07/37] Implement and integrate an aligned buffer to accelerate
write operations.
---
.../TapeDriveWin32Backend.lto-direct.cs | 198 +++++++-----------
.../TapeFilePacker/ITapeWriteBackend.cs | 17 +-
.../TapeFilePacker/MemoryTapeWriteBackend.cs | 8 +-
.../TapeFilePacker/TapeFileWritePacker.cs | 71 ++++---
.../WorkerThreadTapeWriteBackend.cs | 19 +-
TapeLibNET/TapeStreamManager.cs | 25 ++-
TapeLibNET/TapeWriteBuffer.cs | 182 ++++++++++++++++
7 files changed, 330 insertions(+), 190 deletions(-)
create mode 100644 TapeLibNET/TapeWriteBuffer.cs
diff --git a/TapeLibNET/TapeDriveWin32Backend.lto-direct.cs b/TapeLibNET/TapeDriveWin32Backend.lto-direct.cs
index 653248b..2faf511 100644
--- a/TapeLibNET/TapeDriveWin32Backend.lto-direct.cs
+++ b/TapeLibNET/TapeDriveWin32Backend.lto-direct.cs
@@ -62,11 +62,12 @@ namespace TapeLibNET;
/// logical block) and still requires a single transfer within the SRB ceiling.
///
///
-/// The buffer should also be adapter/cache aligned. A pinned managed array is only 8-byte aligned, so
-/// a 64 KB payload spans 17 physical pages — exactly the common adapter SG limit — which is why
-/// unaligned SPTD writes fail above 64 KB. defaults to the page-aligned
-/// path (useAligned: true); pass useAligned: false to exercise the raw pinned-buffer path
-/// for small transfers or diagnostics.
+/// Buffer alignment (zero-copy fast path): the miniport locks the caller's buffer into a
+/// scatter/gather list, so a page-aligned buffer occupies the fewest fragments and can be DMA'd
+/// DIRECTLY. auto-detects alignment from the pinned pointer: an
+/// already page-aligned payload (e.g. a POH window) is pinned and used
+/// in place with NO copy; a misaligned payload is copied once into a reusable page-aligned native
+/// scratch buffer. No "isAligned" flag has to cross the API boundary — the memory is self-describing.
///
///
public partial class TapeDriveWin32Backend
@@ -168,6 +169,7 @@ public readonly struct ScsiDirectOutcome
IsCheckCondition && Eom &&
(SenseKey == c_senseKeyVolumeOverflow || (Asc == c_ascNoAdditionalSense && Ascq == c_ascqEndOfPartition));
*/
+
///
/// Programmable Early Warning: drive reports the configured PEW trip point via
/// ASC/ASCQ 00/07, sense key NO SENSE. Distinct from the built-in EW below and
@@ -207,19 +209,20 @@ public readonly struct ScsiDirectOutcome
/// Unlike (which appends the payload after the sense
/// area in one buffered block), the payload here lives in its own pinned buffer
/// referenced by DataBuffer. The control buffer passed to DeviceIoControl is
- /// [SCSI_PASS_THROUGH_DIRECT][sense].
+ /// [SCSI_PASS_THROUGH_DIRECT][sense] and is stackalloc'd (no per-call heap allocation).
+ ///
+ ///
+ /// Adaptive alignment: the caller's buffer is pinned and its address inspected. When it is
+ /// already page-aligned (e.g. a POH window) the data is DMA'd DIRECTLY
+ /// with no copy; otherwise it is copied once into the reusable page-aligned native scratch. This makes
+ /// large transfers succeed regardless of the caller's alignment while giving aligned callers the
+ /// zero-copy fast path — with no flag crossing the API.
///
///
/// Sense is decoded on EVERY successful transport, including CHECK CONDITION — that is
/// precisely how Early Warning is caught. This method does NOT set the backend error
/// state; the caller decides what a given sense condition means (EW is not an error).
///
- ///
- /// Note: this pins the caller's (managed) buffer directly, which is only 8-byte
- /// aligned. That is fine for small transfers (INQUIRY-sized, and tape blocks up to ~64 KB)
- /// but will fail for larger blocks on adapters with a tight scatter/gather budget. For large
- /// payloads use .
- ///
///
/// Command Descriptor Block (up to 16 bytes).
///
@@ -237,11 +240,26 @@ private unsafe ScsiDirectOutcome SendScsiCommandDirect(
int sptdSize = sizeof(SCSI_PASS_THROUGH_DIRECT);
int ctrlSize = sptdSize + c_senseBufferSize;
- byte[] ctrl = new byte[ctrlSize];
+ // Control block (SPTD + sense) is tiny (~76 bytes) — stackalloc it to avoid a per-command
+ // heap allocation on the hot write path.
+ Span ctrl = stackalloc byte[ctrlSize];
fixed (byte* pCtrl = ctrl)
- fixed (byte* pData = dataBuffer) // null when dataBuffer is empty
+ fixed (byte* pManaged = dataBuffer) // null when dataBuffer is empty; pins the POH window in place
{
+ // Choose the DMA source. A page-aligned payload is used in place (zero copy); a misaligned
+ // one is copied once into the reusable page-aligned scratch. The driver always receives a
+ // page-aligned DataBuffer either way, so a full-budget chunk never over-runs the SG list.
+ byte* pData = pManaged;
+ bool needCopy = dataBuffer.Length > 0 && ((nint)pManaged & (c_pageSize - 1)) != 0;
+ if (needCopy)
+ {
+ byte* scratch = EnsureAlignedScratch(dataBuffer.Length);
+ if (!dataIn)
+ dataBuffer.CopyTo(new Span(scratch, dataBuffer.Length)); // managed -> aligned
+ pData = scratch;
+ }
+
var spt = (SCSI_PASS_THROUGH_DIRECT*)pCtrl;
spt->Length = (ushort)sptdSize;
spt->CdbLength = (byte)cdb.Length;
@@ -265,11 +283,19 @@ private unsafe ScsiDirectOutcome SendScsiCommandDirect(
if (!ok)
{
SetErrorFromPInvoke();
- m_logger.LogDebug("{Prefix}: SPTD DeviceIoControl failed (transport)", LogPrefix);
+ m_logger.LogDebug("{Prefix}: SPTD DeviceIoControl failed (transport){Copied}",
+ LogPrefix, needCopy ? " [copied]" : "");
return new ScsiDirectOutcome { TransportOk = false };
}
- return DecodeSptdSense(pCtrl, sptdSize, spt->ScsiStatus, spt->DataTransferLength, "SPTD");
+ ScsiDirectOutcome outcome =
+ DecodeSptdSense(pCtrl, sptdSize, spt->ScsiStatus, spt->DataTransferLength, "SPTD");
+
+ // Data-in via scratch: copy the result back into the caller's (misaligned) span.
+ if (needCopy && dataIn && dataBuffer.Length > 0 && outcome.TransportOk)
+ new Span(pData, dataBuffer.Length).CopyTo(dataBuffer);
+
+ return outcome;
}
}
@@ -297,18 +323,10 @@ private unsafe ScsiDirectOutcome SendScsiCommandDirect(
/// refused with ERROR_INSUFFICIENT_BUFFER.
///
///
- /// Transport per chunk is selected by :
- ///
- ///
- /// true (default): route each chunk through a reusable page-aligned native buffer
- /// (). Required for chunks larger than ~64 KB.
- ///
- ///
- /// false: pin the caller's managed buffer directly ().
- /// Lower overhead, but limited to ~64 KB on adapters with a small scatter/gather budget; the
- /// per-chunk budget is reduced by one page to tolerate the misaligned head.
- ///
- ///
+ /// Each chunk is transported by , which auto-detects buffer
+ /// alignment: a page-aligned payload is DMA'd with no copy, a misaligned one is copied into the
+ /// page-aligned scratch. Callers wanting the zero-copy fast path should supply a
+ /// -backed, page-aligned array.
///
///
/// Source buffer.
@@ -317,6 +335,7 @@ private unsafe ScsiDirectOutcome SendScsiCommandDirect(
/// Byte count to write. In fixed-block mode this must be a whole multiple of the current
/// ; it may exceed one SRB and will be chunked automatically.
///
+ /// true if a filemark condition was reported.
///
/// true if the drive reported Programmable Early Warning (the earlier, host-configured
/// trip point). The data up to the return value WAS written. LTO-5+ only; requires a PEWS to have
@@ -328,14 +347,12 @@ private unsafe ScsiDirectOutcome SendScsiCommandDirect(
/// wrap-up. Not an error.
///
/// true on hard physical EOM. The last chunk's data was NOT written.
- /// true if a filemark condition was reported.
- /// Use the page-aligned transport (default); required for large chunks.
/// Force variable-block mode regardless of .
/// The total number of payload bytes the drive accepted across all chunks.
internal int ScsiWriteDirect(
byte[] buffer, int offset, int count,
out bool tapemark, out bool programmableEarlyWarning, out bool earlyWarning, out bool eom,
- bool useAligned = true, bool forceVariable = false)
+ bool forceVariable = false)
{
programmableEarlyWarning = false;
earlyWarning = false;
@@ -370,13 +387,11 @@ internal int ScsiWriteDirect(
return 0;
}
- // One SPTD command cannot exceed the adapter's per-SRB ceiling. The raw (unaligned) path
- // can lose one page to a misaligned head, so leave a page of headroom there. Also honor
- // the WRITE(6) 24-bit length field.
- uint srbMax = MaxScsiDirectTransfer;
- uint effectiveMax = useAligned
- ? srbMax
- : (srbMax > (uint)c_pageSize ? srbMax - (uint)c_pageSize : srbMax);
+ // One SPTD command cannot exceed the adapter's per-SRB ceiling. The transport always presents a
+ // page-aligned DataBuffer (either the caller's aligned window or the aligned scratch), so a
+ // full-budget chunk occupies at most MaximumPhysicalPages fragments — no headroom needed.
+ // Also honor the WRITE(6) 24-bit length field.
+ uint effectiveMax = MaxScsiDirectTransfer;
// Compute the per-command chunk size (bytes).
int chunkBytes;
@@ -419,7 +434,7 @@ internal int ScsiWriteDirect(
{
int thisCount = Math.Min(remaining, chunkBytes);
- ScsiDirectOutcome r = WriteScsiChunk(buffer, pos, thisCount, fixedBlock, blockSize, useAligned);
+ ScsiDirectOutcome r = WriteScsiChunk(buffer, pos, thisCount, fixedBlock, blockSize);
if (!r.TransportOk)
{
@@ -506,7 +521,7 @@ internal int ScsiWriteDirect(
/// outcome so that chunk accounting (residual, EW/PEW/EOM latching) stays in one place.
///
private ScsiDirectOutcome WriteScsiChunk(
- byte[] buffer, int offset, int count, bool fixedBlock, uint blockSize, bool useAligned)
+ byte[] buffer, int offset, int count, bool fixedBlock, uint blockSize)
{
uint xferLen;
byte flags;
@@ -523,7 +538,6 @@ private ScsiDirectOutcome WriteScsiChunk(
}
// xferLen is guaranteed within the WRITE(6) 24-bit field by the chunk sizing in the caller.
-
#pragma warning disable IDE0302 // Simplify collection initialization -- for explicity
Span cdb = stackalloc byte[6];
#pragma warning restore IDE0302 // Simplify collection initialization
@@ -534,25 +548,24 @@ private ScsiDirectOutcome WriteScsiChunk(
cdb[4] = (byte)(xferLen & 0xFF);
cdb[5] = 0x00; // CONTROL
+ // The transport auto-detects page alignment: a POH-backed aligned slice is DMA'd directly,
+ // otherwise it is copied into the page-aligned scratch.
Span payload = buffer.AsSpan(offset, count);
-
- return useAligned
- ? SendScsiCommandDirectAligned(cdb, payload, dataIn: false)
- : SendScsiCommandDirect(cdb, payload, dataIn: false);
+ return SendScsiCommandDirect(cdb, payload, dataIn: false);
}
///
/// Convenience wrapper matching the override shape,
/// so a caller can swap WriteFile for SPTD writes with minimal churn. Both early-warning kinds
- /// are surfaced through dedicated out flags; a hard EOM surfaces through
- /// exactly like the WriteFile path. Uses the page-aligned transport.
+ /// are surfaced through dedicated out flags; a hard EOM surfaces through .
+ /// Supply a -backed, page-aligned array for the zero-copy fast path.
///
public int WriteDirect(byte[] buffer, int offset, int count,
out bool tapemark, out bool programmableEarlyWarning, out bool earlyWarning, out bool eom)
{
int written = ScsiWriteDirect(buffer, offset, count,
out tapemark, out programmableEarlyWarning, out earlyWarning, out eom,
- useAligned: true, forceVariable: false);
+ forceVariable: false);
return written;
}
@@ -637,13 +650,11 @@ internal bool ScsiWriteFilemarksDirect(int count, bool immediate, out bool early
// 64 KB cliff. WriteFile avoids this because the tape class driver builds a page-aligned
// MDL for the full transfer AND splits it into adapter-sized SRBs internally.
//
- // Fix: (1) probe adapter capabilities via IOCTL_STORAGE_QUERY_PROPERTY; (2) route each
- // chunk through a reusable page-aligned native scratch buffer; (3) CHUNK a large fixed-block
- // write across multiple SRB-sized WRITE(6) commands in ScsiWriteDirect (a tape logical block
- // itself cannot be split, so variable-block writes still must fit one SRB).
- //
- // The non-aligned transport (SendScsiCommandDirect) is retained for small transfers
- // and diagnostics; ScsiWriteDirect selects between them via useAligned.
+ // Fix: (1) probe adapter capabilities via IOCTL_STORAGE_QUERY_PROPERTY; (2) DMA directly
+ // from an already page-aligned caller buffer (a TapeWriteBuffer POH window) or, for a
+ // misaligned buffer, copy once through a reusable page-aligned native scratch; (3) CHUNK a
+ // large fixed-block write across multiple SRB-sized WRITE(6) commands in ScsiWriteDirect
+ // (a tape logical block itself cannot be split, so variable-block writes still must fit one SRB).
// =========================================================================
// =============================================================================
@@ -697,7 +708,8 @@ internal bool ScsiWriteFilemarksDirect(int count, bool immediate, out bool early
private uint m_maxPhysicalPages;
private uint m_alignmentMask;
- // Reusable page-aligned scratch for SPTD payloads. Allocated on demand, grown as needed.
+ // Reusable page-aligned scratch for MISALIGNED SPTD payloads (aligned callers skip this entirely).
+ // Allocated on demand, grown as needed.
// IMPORTANT: call FreeAlignedScratch() from your existing Close(), and reset
// m_maxTransferLength = 0 there too so capabilities are re-probed on the next Open().
private nint m_alignedScratch; // native, page-aligned
@@ -840,82 +852,12 @@ internal unsafe void FreeAlignedScratch()
#endregion
- #region *** Aligned SPTD core ***
-
- ///
- /// Page-aligned variant of . The payload is copied into a
- /// reusable page-aligned native buffer before the IOCTL, and (for data-in) copied back after.
- /// This is what makes transfers larger than 64 KB succeed. The scratch buffer only needs to be
- /// as large as ONE chunk (), not the caller's whole write.
- ///
- private unsafe ScsiDirectOutcome SendScsiCommandDirectAligned(
- Span cdb,
- Span dataBuffer,
- bool dataIn,
- uint timeoutSeconds = c_sptiDefaultTimeoutSec)
- {
- int sptdSize = sizeof(SCSI_PASS_THROUGH_DIRECT);
- int ctrlSize = sptdSize + c_senseBufferSize;
-
- byte[] ctrl = new byte[ctrlSize];
-
- byte* pData = null;
- if (dataBuffer.Length > 0)
- {
- pData = EnsureAlignedScratch(dataBuffer.Length);
- if (!dataIn)
- dataBuffer.CopyTo(new Span(pData, dataBuffer.Length)); // managed -> aligned
- }
-
- fixed (byte* pCtrl = ctrl)
- {
- var spt = (SCSI_PASS_THROUGH_DIRECT*)pCtrl;
- spt->Length = (ushort)sptdSize;
- spt->CdbLength = (byte)cdb.Length;
- spt->SenseInfoLength = (byte)c_senseBufferSize;
- spt->DataIn = dataIn ? c_scsiDataIn : c_scsiDataOut;
- spt->DataTransferLength = (uint)dataBuffer.Length;
- spt->TimeOutValue = timeoutSeconds;
- spt->DataBuffer = dataBuffer.Length > 0 ? pData : null;
- spt->SenseInfoOffset = (uint)sptdSize;
-
- for (int i = 0; i < cdb.Length; i++)
- spt->Cdb[i] = cdb[i];
-
- bool ok = PInvoke.DeviceIoControl(
- new HANDLE(m_driveHandle.DangerousGetHandle()),
- c_ioctlScsiPassThroughDirect,
- pCtrl, (uint)ctrlSize,
- pCtrl, (uint)ctrlSize,
- null, null);
-
- if (!ok)
- {
- SetErrorFromPInvoke();
- m_logger.LogDebug("{Prefix}: SPTD(aligned) DeviceIoControl failed (transport), Win32=0x{Err:X}",
- LogPrefix, (uint)LastErrorWin32);
- return new ScsiDirectOutcome { TransportOk = false };
- }
-
- ScsiDirectOutcome outcome =
- DecodeSptdSense(pCtrl, sptdSize, spt->ScsiStatus, spt->DataTransferLength, "SPTD(aligned)");
-
- // Data-in: copy the aligned scratch back into the caller's span.
- if (dataIn && dataBuffer.Length > 0 && outcome.TransportOk)
- new Span(pData, dataBuffer.Length).CopyTo(dataBuffer);
-
- return outcome;
- }
- }
-
- #endregion
-
#region *** SPTD sense decode (shared) ***
///
/// Decodes the fixed-format sense buffer that immediately follows the SPTD control block,
- /// building a . Shared by the raw and aligned transports so
- /// EW / PEW / EOM detection stays identical across both.
+ /// building a . Shared by all SPTD commands so
+ /// EW / PEW / EOM detection stays identical everywhere.
///
private unsafe ScsiDirectOutcome DecodeSptdSense(
byte* pCtrl, int sptdSize, byte scsiStatus, uint dataTransferLength, string tag)
diff --git a/TapeLibNET/TapeFilePacker/ITapeWriteBackend.cs b/TapeLibNET/TapeFilePacker/ITapeWriteBackend.cs
index d294a74..48b2726 100644
--- a/TapeLibNET/TapeFilePacker/ITapeWriteBackend.cs
+++ b/TapeLibNET/TapeFilePacker/ITapeWriteBackend.cs
@@ -49,9 +49,12 @@ internal readonly record struct WriteResult(
/// hard errors and will be packaged by the backend into
/// .
///
-/// Caller-owned buffer containing the bytes to write. Read-only for the sink.
-/// Block-aligned count of bytes to write from offset 0.
-internal delegate WriteResult TapeWriteSink(byte[] buffer, int validBytes);
+///
+/// Caller-owned, page-aligned write buffer containing the bytes to write. Read-only for the sink.
+/// Being page-aligned (a POH window), it lets the SPTD path DMA directly with no intermediate copy.
+///
+/// Block-aligned count of bytes to write from the start of the buffer window.
+internal delegate WriteResult TapeWriteSink(TapeWriteBuffer buffer, int validBytes);
///
/// Low-layer tape write abstraction used by TapeFileWritePacker. Hides the
@@ -74,12 +77,12 @@ internal interface ITapeWriteBackend : IDisposable
/// of until it is returned by
/// (or by an internal completion harvested by the next ).
///
- /// Buffer of bytes to write; ownership transfers to the backend.
+ /// Page-aligned buffer to write; ownership transfers to the backend.
///
- /// Number of bytes from offset 0 to write. Should be a multiple of
+ /// Number of bytes from the start of the buffer window to write. Should be a multiple of
/// ; the sink will round down if not.
///
- void StartWriting(byte[] buffer, int validBytes);
+ void StartWriting(TapeWriteBuffer buffer, int validBytes);
/// Non-blocking snapshot. when no write is in flight.
WriteBackendStatus PollStatus();
@@ -90,5 +93,5 @@ internal interface ITapeWriteBackend : IDisposable
/// When no write is in flight, returns (, null).
/// Idempotent.
///
- (WriteResult Result, byte[]? Buffer) AwaitCompletion();
+ (WriteResult Result, TapeWriteBuffer? Buffer) AwaitCompletion();
}
diff --git a/TapeLibNET/TapeFilePacker/MemoryTapeWriteBackend.cs b/TapeLibNET/TapeFilePacker/MemoryTapeWriteBackend.cs
index c35224b..99506ff 100644
--- a/TapeLibNET/TapeFilePacker/MemoryTapeWriteBackend.cs
+++ b/TapeLibNET/TapeFilePacker/MemoryTapeWriteBackend.cs
@@ -70,7 +70,7 @@ public void SetPerWriteDelay(TimeSpan delay)
lock (_stateLock) _perWriteDelay = delay;
}
- private WriteResult Sink(byte[] buffer, int validBytes)
+ private WriteResult Sink(TapeWriteBuffer buffer, int validBytes)
{
TimeSpan delay;
long eomAfter;
@@ -113,7 +113,7 @@ private WriteResult Sink(byte[] buffer, int validBytes)
if (acceptedBytes > 0)
{
var copy = new byte[acceptedBytes];
- Buffer.BlockCopy(buffer, 0, copy, 0, acceptedBytes);
+ Buffer.BlockCopy(buffer.Array, buffer.Offset, copy, 0, acceptedBytes);
lock (_stateLock)
{
_written.Add(copy);
@@ -124,9 +124,9 @@ private WriteResult Sink(byte[] buffer, int validBytes)
return new WriteResult(blocksAccepted, eom, error);
}
- public void StartWriting(byte[] buffer, int validBytes) => _inner.StartWriting(buffer, validBytes);
+ public void StartWriting(TapeWriteBuffer buffer, int validBytes) => _inner.StartWriting(buffer, validBytes);
public WriteBackendStatus PollStatus() => _inner.PollStatus();
- public (WriteResult Result, byte[]? Buffer) AwaitCompletion() => _inner.AwaitCompletion();
+ public (WriteResult Result, TapeWriteBuffer? Buffer) AwaitCompletion() => _inner.AwaitCompletion();
public void Dispose() => _inner.Dispose();
}
diff --git a/TapeLibNET/TapeFilePacker/TapeFileWritePacker.cs b/TapeLibNET/TapeFilePacker/TapeFileWritePacker.cs
index 0129b7d..3b419ae 100644
--- a/TapeLibNET/TapeFilePacker/TapeFileWritePacker.cs
+++ b/TapeLibNET/TapeFilePacker/TapeFileWritePacker.cs
@@ -1,4 +1,3 @@
-using System.Buffers;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -21,13 +20,18 @@ namespace TapeLibNET.TapeFilePacker;
/// ). Closed files become "pending commit" until their tail block
/// is committed, at which point they are reported via .
///
+///
+/// Fill buffers are page-aligned instances rented from a
+/// . Page alignment lets the SPTD write path DMA directly with
+/// no intermediate copy; double-buffering means up to two buffers of the same size are live at once,
+/// which the pool supports natively.
+///
///
internal sealed class TapeFileWritePacker : IDisposable
{
// -----------------------------------------------------------------------
// Construction & external dependencies
// -----------------------------------------------------------------------
-
private readonly ITapeWriteBackend _backend;
private readonly Action? _rewindToBlock;
private readonly SourceErrorMode _sourceErrorMode;
@@ -35,13 +39,18 @@ internal sealed class TapeFileWritePacker : IDisposable
private readonly int _blockSize;
private readonly int _bufferCapacity;
+ // Page-aligned buffer pool. When the caller supplies one (production: shared per drive) we do
+ // NOT own it; when omitted we create and dispose our own so unit tests need no extra wiring.
+ private readonly TapeWriteBufferPool _pool;
+ private readonly bool _ownsPool;
+
// -----------------------------------------------------------------------
// Buffer & tape position state
// -----------------------------------------------------------------------
// Current fill buffer (we own it until handing off to backend, at which point
// ownership transfers and we replace it with a fresh one).
- private byte[] _fillBuffer;
+ private TapeWriteBuffer _fillBuffer;
// Bytes written into _fillBuffer that have NOT yet been handed to the backend.
// Includes leftover sub-block bytes carried over from the previous handoff.
@@ -60,7 +69,6 @@ internal sealed class TapeFileWritePacker : IDisposable
// -----------------------------------------------------------------------
// File registry
// -----------------------------------------------------------------------
-
private sealed class PendingEntry
{
public CommitToken Token;
@@ -82,11 +90,10 @@ private sealed class PendingEntry
private bool _disposed;
// -----------------------------------------------------------------------
-
/// Low-layer write backend (worker thread or test fake).
///
/// Callback used by in
- /// mode and by . Pass b => mgr.Drive.MoveToBlock((int)b)
+ /// mode and by . Pass b => mgr.Drive.MoveToBlock((int)b)
/// in production. May be null in unit tests that never exercise rollback paths.
///
/// Buffer size = blockMultiplier × BlockSize. Must be ≥ 1.
@@ -100,13 +107,18 @@ private sealed class PendingEntry
/// absolute on-tape coordinates -- matching the legacy backup's TOC convention and
/// enabling correct packed restore across multi-set tapes.
///
+ ///
+ /// Shared page-aligned buffer pool (production: one per drive). When null the packer
+ /// creates and owns a private pool. Not disposed when supplied by the caller.
+ ///
public TapeFileWritePacker(
ITapeWriteBackend backend,
Action? rewindToBlock = null,
int blockMultiplier = 16,
SourceErrorMode sourceErrorMode = SourceErrorMode.NoRollback,
ILogger? logger = null,
- long initialAbsBlock = 0)
+ long initialAbsBlock = 0,
+ TapeWriteBufferPool? bufferPool = null)
{
ArgumentNullException.ThrowIfNull(backend);
if (blockMultiplier < 1)
@@ -121,7 +133,10 @@ public TapeFileWritePacker(
_blockSize = checked((int)backend.BlockSize);
_bufferCapacity = checked(blockMultiplier * _blockSize);
- _fillBuffer = ArrayPool.Shared.Rent(_bufferCapacity);
+ // Own a private pool only when the caller didn't supply one (keeps unit tests trivial).
+ _ownsPool = bufferPool is null;
+ _pool = bufferPool ?? new TapeWriteBufferPool(_logger);
+ _fillBuffer = _pool.Rent(_bufferCapacity);
// Anchor packer position to the current drive head so addresses are absolute.
_committedTapeBlock = initialAbsBlock;
@@ -131,7 +146,6 @@ public TapeFileWritePacker(
// -----------------------------------------------------------------------
// Public events & state
// -----------------------------------------------------------------------
-
///
/// Fired (synchronously, on the calling thread) whenever one or more files cross the
/// commit boundary as a side effect of ,
@@ -148,7 +162,6 @@ public TapeFileWritePacker(
// -----------------------------------------------------------------------
// Public API: BeginFile / EndFile
// -----------------------------------------------------------------------
-
///
/// Open a logical write slot for one file. Returns a stream the caller writes the
/// source bytes into. The file's is not known yet; it is
@@ -174,7 +187,6 @@ public TapeWriteStreamFacade BeginFile()
};
_pending.Add(entry);
_openEntry = entry;
-
_openStream = new TapeWriteStreamFacade(this, entry.Token);
return _openStream;
}
@@ -202,14 +214,12 @@ public CommitToken EndFile()
// but it is NOT durably on tape until the buffer is flushed and harvested.
// We do not promote here. Promotion happens on the next harvest.
TryPromoteCommittables();
-
return token;
}
// -----------------------------------------------------------------------
// Public API: discard / rollback / flush
// -----------------------------------------------------------------------
-
///
/// Discard the open file according to . Both modes
/// truncate the still-buffered tail of the open file. Rollback mode additionally
@@ -223,7 +233,6 @@ public void DiscardOpenFile()
throw new InvalidOperationException("No file is open.");
var entry = _openEntry;
-
if (_sourceErrorMode == SourceErrorMode.NoRollback)
{
// Truncate fill back to the open file's start (or to 0 if the start has
@@ -274,7 +283,6 @@ private void DiscardOpenFile_Rollback(PendingEntry entry)
// committed file's tail shares it and we accept the open file's first partial
// block as on-tape garbage (see §4.13.5).
long targetBlock = (startAbs + _blockSize - 1) / _blockSize;
-
try
{
_rewindToBlock(targetBlock);
@@ -327,7 +335,6 @@ public IReadOnlyList RollbackPending()
// Optional rewind: caller may want the tape head exactly at the committed
// boundary. We only call rewind if a callback was supplied.
_rewindToBlock?.Invoke(_committedTapeBlock);
-
return rolled;
}
@@ -346,9 +353,8 @@ public void Flush()
{
int padded = ((_fillPos + _blockSize - 1) / _blockSize) * _blockSize;
if (padded > _fillPos)
- Array.Clear(_fillBuffer, _fillPos, padded - _fillPos);
+ _fillBuffer.Clear(_fillPos, padded - _fillPos);
_fillPos = padded;
-
DoFlushFillBuffer();
}
@@ -359,7 +365,6 @@ public void Flush()
// -----------------------------------------------------------------------
// Internal API: stream-facing write hook
// -----------------------------------------------------------------------
-
/// Called by on each Write call.
internal void WriteFromOpenFile(byte[] buffer, int offset, int count)
{
@@ -377,7 +382,7 @@ internal void WriteFromOpenFile(byte[] buffer, int offset, int count)
}
int chunk = Math.Min(free, count);
- Buffer.BlockCopy(buffer, offset, _fillBuffer, _fillPos, chunk);
+ _fillBuffer.CopyFrom(buffer.AsSpan(offset, chunk), _fillPos);
_fillPos += chunk;
offset += chunk;
count -= chunk;
@@ -388,7 +393,6 @@ internal void WriteFromOpenFile(byte[] buffer, int offset, int count)
// -----------------------------------------------------------------------
// Buffer handoff & harvesting
// -----------------------------------------------------------------------
-
// Hands off the block-aligned prefix of _fillBuffer to the backend and rotates in
// a fresh fill buffer with the trailing sub-block bytes carried over.
private void DoFlushFillBuffer()
@@ -402,15 +406,13 @@ private void DoFlushFillBuffer()
HarvestNow();
int leftover = _fillPos - validBytes;
-
- var newFill = ArrayPool.Shared.Rent(_bufferCapacity);
+ var newFill = _pool.Rent(_bufferCapacity);
if (leftover > 0)
- Buffer.BlockCopy(_fillBuffer, validBytes, newFill, 0, leftover);
+ _fillBuffer.CopyRegionTo(newFill, validBytes, leftover);
// Hand off; ownership of the old buffer transfers to the backend until harvest.
_backend.StartWriting(_fillBuffer, validBytes);
_inflightValidBytes = validBytes;
-
_fillBuffer = newFill;
_fillPos = leftover;
_baseAbsByteOfFill += validBytes;
@@ -429,8 +431,7 @@ private void TryProactiveHarvest()
private void HarvestNow(bool suppressEomThrow = false)
{
var (result, returnedBuffer) = _backend.AwaitCompletion();
- if (returnedBuffer is not null)
- ArrayPool.Shared.Return(returnedBuffer);
+ returnedBuffer?.Return();
if (result.BlocksWritten == 0 && result.Exception is null && !result.EomEncountered)
return; // nothing to do (idempotent harvest)
@@ -438,7 +439,6 @@ private void HarvestNow(bool suppressEomThrow = false)
long blocksWritten = result.BlocksWritten;
_committedTapeBlock += blocksWritten;
_inflightValidBytes = 0;
-
TryPromoteCommittables();
if (result.Exception is not null)
@@ -461,16 +461,15 @@ private void HarvestNow(bool suppressEomThrow = false)
// -----------------------------------------------------------------------
// Promotion & post-EOM rollback bookkeeping
// -----------------------------------------------------------------------
-
private void TryPromoteCommittables()
{
if (_pending.Count == 0)
return;
long committedAbsByte = _committedTapeBlock * (long)_blockSize;
-
List? committed = null;
int writeIdx = 0;
+
for (int readIdx = 0; readIdx < _pending.Count; readIdx++)
{
var entry = _pending[readIdx];
@@ -491,6 +490,7 @@ private void TryPromoteCommittables()
writeIdx++;
}
}
+
if (writeIdx != _pending.Count)
_pending.RemoveRange(writeIdx, _pending.Count - writeIdx);
@@ -512,7 +512,6 @@ private CommitToken[] CollectAndRollbackUncommittedPending()
continue; // leave open entry alone; agent will Discard it
rolled.Add(e.Token);
}
-
_pending.RemoveAll(e => !e.IsOpen);
// Reset fill to the committed boundary; everything in fill is gone.
@@ -527,7 +526,6 @@ private CommitToken[] CollectAndRollbackUncommittedPending()
_openEntry.StartAbsByte = _baseAbsByteOfFill;
_openEntry.Length = 0;
}
-
return [.. rolled];
}
@@ -538,7 +536,6 @@ private void RollbackUncommittedPending()
// -----------------------------------------------------------------------
// Disposal
// -----------------------------------------------------------------------
-
public void Dispose()
{
if (_disposed)
@@ -560,10 +557,16 @@ public void Dispose()
// returned by the final HarvestNow inside Flush.
if (_fillBuffer is not null)
{
- try { ArrayPool.Shared.Return(_fillBuffer); } catch { /* ignore */ }
+ try { _fillBuffer.Return(); } catch { /* ignore */ }
_fillBuffer = null!;
}
+ // Dispose the pool only if we created it (a caller-supplied pool is shared / caller-owned).
+ if (_ownsPool)
+ {
+ try { _pool.Dispose(); } catch { /* ignore */ }
+ }
+
_openStream?.MarkClosed();
_openStream = null;
diff --git a/TapeLibNET/TapeFilePacker/WorkerThreadTapeWriteBackend.cs b/TapeLibNET/TapeFilePacker/WorkerThreadTapeWriteBackend.cs
index d9791a2..f7b2053 100644
--- a/TapeLibNET/TapeFilePacker/WorkerThreadTapeWriteBackend.cs
+++ b/TapeLibNET/TapeFilePacker/WorkerThreadTapeWriteBackend.cs
@@ -31,9 +31,9 @@ internal sealed class WorkerThreadTapeWriteBackend : ITapeWriteBackend
// Protected by the lock; mutated only by the producer (under lock) before signaling
// _workAvailable, and by the worker (under lock) before signaling _workComplete.
private readonly object _lock = new();
- private byte[]? _pendingBuffer;
+ private TapeWriteBuffer? _pendingBuffer;
private int _pendingValidBytes;
- private byte[]? _completedBuffer;
+ private TapeWriteBuffer? _completedBuffer;
private WriteResult _completedResult;
private bool _hasCompletedResult;
private bool _shutdownRequested;
@@ -50,7 +50,6 @@ public WorkerThreadTapeWriteBackend(TapeWriteSink sink, uint blockSize, ILogger?
_sink = sink;
BlockSize = blockSize;
_logger = logger ?? NullLogger.Instance;
-
_worker = new Thread(WorkerLoop)
{
IsBackground = true,
@@ -59,12 +58,11 @@ public WorkerThreadTapeWriteBackend(TapeWriteSink sink, uint blockSize, ILogger?
_worker.Start();
}
- public void StartWriting(byte[] buffer, int validBytes)
+ public void StartWriting(TapeWriteBuffer buffer, int validBytes)
{
ArgumentNullException.ThrowIfNull(buffer);
ArgumentOutOfRangeException.ThrowIfNegative(validBytes);
- ArgumentOutOfRangeException.ThrowIfGreaterThan(validBytes, buffer.Length);
-
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(validBytes, buffer.Capacity);
ThrowIfDisposed();
// Block until the previous write (if any) has finished.
@@ -77,7 +75,6 @@ public void StartWriting(byte[] buffer, int validBytes)
lock (_lock)
{
ThrowIfDisposed();
-
Debug.Assert(_pendingBuffer is null, "Worker should be idle after _workComplete is set.");
_pendingBuffer = buffer;
@@ -101,7 +98,7 @@ public WriteBackendStatus PollStatus()
return _workComplete.IsSet ? WriteBackendStatus.Idle : WriteBackendStatus.Busy;
}
- public (WriteResult Result, byte[]? Buffer) AwaitCompletion()
+ public (WriteResult Result, TapeWriteBuffer? Buffer) AwaitCompletion()
{
if (_disposed)
return (WriteResult.Empty, null);
@@ -115,11 +112,9 @@ public WriteBackendStatus PollStatus()
var result = _completedResult;
var buffer = _completedBuffer;
-
_hasCompletedResult = false;
_completedBuffer = null;
_completedResult = default;
-
return (result, buffer);
}
}
@@ -132,9 +127,8 @@ private void WorkerLoop()
{
_workAvailable.Wait();
- byte[] buffer;
+ TapeWriteBuffer buffer;
int validBytes;
-
lock (_lock)
{
if (_shutdownRequested)
@@ -202,7 +196,6 @@ public void Dispose()
}
try { _worker.Join(); } catch { /* ignore */ }
-
_workAvailable.Dispose();
_workComplete.Dispose();
}
diff --git a/TapeLibNET/TapeStreamManager.cs b/TapeLibNET/TapeStreamManager.cs
index 9534e69..38491ce 100644
--- a/TapeLibNET/TapeStreamManager.cs
+++ b/TapeLibNET/TapeStreamManager.cs
@@ -43,6 +43,7 @@ public class TapeStreamManager : TapeDriveHolder
// by EndWriteContent. Both remain null while the manager is not in
// TapeState.WritingContent.
private WorkerThreadTapeWriteBackend? m_packerBackend;
+ private TapeWriteBufferPool? m_packerBufferPool;
private TapeFileWritePacker? m_packer;
// Bytes already handed off to the drive by the packer in the current content
@@ -797,7 +798,7 @@ private bool CheckContentCapacity(long length, long writtenSoFar)
// Sink that bridges the worker-thread backend to TapeDrive.WriteDirect.
// Captured once and passed to the backend; lives for the backend's lifetime.
- private WriteResult PackerWriteSink(byte[] buffer, int validBytes)
+ private WriteResult PackerWriteSink(TapeWriteBuffer buffer, int validBytes)
{
try
{
@@ -829,7 +830,7 @@ private WriteResult PackerWriteSink(byte[] buffer, int validBytes)
int partialBlocks = 0;
if (writable > 0)
{
- int w = Drive.WriteDirect(buffer, 0, writable);
+ int w = Drive.WriteDirect(buffer.Array, buffer.Offset, writable);
partialBlocks = w / (int)blockSize;
m_packerBytesWritten += w;
}
@@ -840,7 +841,7 @@ private WriteResult PackerWriteSink(byte[] buffer, int validBytes)
DriveNumber, validBytes, m_packerBytesWritten, remaining);
}
- int written = Drive.WriteDirect(buffer, 0, validBytes, out _, out bool ew, out bool eom);
+ int written = Drive.WriteDirect(buffer.Array, buffer.Offset, validBytes, out _, out bool ew, out bool eom);
int blocks = written / (int)Drive.BlockSize;
m_packerBytesWritten += written;
@@ -904,13 +905,16 @@ private void EnsurePackerCreated()
if (startBlock < 0)
startBlock = 0;
+ m_packerBufferPool = new(m_logger);
+
m_packer = new TapeFileWritePacker(
backend: m_packerBackend,
rewindToBlock: b => Drive.MoveToBlock(b),
blockMultiplier: PackerBlockMultiplier,
sourceErrorMode: PackerSourceErrorMode,
logger: m_logger,
- initialAbsBlock: startBlock);
+ initialAbsBlock: startBlock,
+ bufferPool: m_packerBufferPool);
m_packer.FilesCommitted += OnPackerFilesCommitted;
@@ -962,6 +966,19 @@ private void FlushAndDisposePacker()
m_packerBackend = null;
}
+ try
+ {
+ m_packerBackend?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ m_logger.LogWarning(ex, "Drive #{Drive}: Exception disposing packer buffer pool", DriveNumber);
+ }
+ finally
+ {
+ m_packerBufferPool = null;
+ }
+
m_logger.LogTrace("Drive #{Drive}: Packer disposed", DriveNumber);
if (pendingEom is not null)
diff --git a/TapeLibNET/TapeWriteBuffer.cs b/TapeLibNET/TapeWriteBuffer.cs
new file mode 100644
index 0000000..d220752
--- /dev/null
+++ b/TapeLibNET/TapeWriteBuffer.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace TapeLibNET;
+
+///
+/// A pooled, system-page-aligned write buffer backed by a Pinned-Object-Heap (POH) array.
+///
+/// The SPTD write path can DMA directly from a page-aligned buffer, skipping the intermediate copy into
+/// native scratch. A POH array never moves, so its data address is stable for life; we over-allocate by
+/// one page and expose a page-aligned WINDOW of bytes. The window's internal start
+/// offset is a private detail — the high layer addresses the window with 0-based positions and never sees it.
+///
+///
+/// This is the storable rental handle (it survives the packer's buffer rotation and the worker-thread
+/// handoff), which is why Rent returns this object rather than a — a ref struct
+/// cannot be stored in a field. The SCSI layer never references this type: the write glue passes the plain
+/// backing array + window offset (both internal), and the SPTD path auto-detects page alignment from
+/// the pinned pointer, so no "isAligned" flag has to cross the boundary.
+///
+///
+public sealed class TapeWriteBuffer
+{
+ private readonly byte[] _array;
+ private readonly int _offset; // page-aligned window start within _array
+
+ internal TapeWriteBuffer(byte[] array, int offset, int capacity, TapeWriteBufferPool owner)
+ {
+ _array = array;
+ _offset = offset;
+ Capacity = capacity;
+ Owner = owner;
+ }
+
+ /// Usable capacity (bytes) of the window. Also the pool bucket key.
+ public int Capacity { get; }
+
+ /// True: the window start is page-aligned (guaranteed by the pool).
+ public static bool IsPageAligned => true;
+
+ internal TapeWriteBufferPool Owner { get; }
+
+ // Backing store + window offset, for the write glue only (drive.WriteDirect is byte[]-based, and the
+ // fixed-buffer pin in the SPTD path needs the real array). Internal, so it stays out of the public
+ // surface — no offset "laundry" leaks to callers.
+ internal byte[] Array => _array;
+ internal int Offset => _offset;
+
+ // -----------------------------------------------------------------------
+ // Window operations — all positions are 0-based within the usable window.
+ // These replace the packer's direct Buffer.BlockCopy / Array.Clear calls so
+ // the private offset stays encapsulated.
+ // -----------------------------------------------------------------------
+
+ /// Copies into the window starting at .
+ public void CopyFrom(ReadOnlySpan src, int destPos)
+ => src.CopyTo(_array.AsSpan(_offset + destPos, Capacity - destPos));
+
+ /// Zero-fills bytes of the window starting at .
+ public void Clear(int start, int length)
+ => System.Array.Clear(_array, _offset + start, length);
+
+ ///
+ /// Copies a region of THIS window into the START of 's window — used to carry
+ /// the trailing sub-block bytes over when the packer rotates in a fresh fill buffer.
+ ///
+ public void CopyRegionTo(TapeWriteBuffer dest, int srcStart, int length)
+ => _array.AsSpan(_offset + srcStart, length).CopyTo(dest._array.AsSpan(dest._offset, length));
+
+ ///
+ /// The first bytes of the (page-aligned) window, as a span. Transient — do not
+ /// store; the backing array is POH so it never moves during the write. Provided for span-based consumers;
+ /// the byte[]-based write path uses the internal / instead.
+ ///
+ public Span Data(int length) => _array.AsSpan(_offset, length);
+
+ /// Returns this buffer to its owning pool for reuse.
+ public void Return() => Owner.Return(this);
+}
+
+///
+/// Pool of page-aligned instances, bucketed by (page-rounded) capacity.
+/// Supports several live rentals of the same size (e.g. the packer's double-buffering), so each bucket is a
+/// stack of free buffers rather than a single slot. Rent/Return are cheap and thread-safe.
+///
+/// Backed by POH arrays (): real [] that never
+/// move and are GC-reclaimed — no manual free, no leak on exception paths. Keep the pooled count small; POH
+/// is not compacted.
+///
+///
+public sealed class TapeWriteBufferPool : IDisposable
+{
+ private readonly object _lock = new();
+ private readonly Dictionary> _free = [];
+ private readonly ILogger _logger;
+ private bool _disposed;
+
+ /// System page size used for alignment (defaults to ).
+ public int PageSize { get; }
+
+ public TapeWriteBufferPool(ILogger? logger = null, int? pageSize = null)
+ {
+ PageSize = pageSize ?? Environment.SystemPageSize;
+ if (PageSize <= 0 || (PageSize & (PageSize - 1)) != 0)
+ throw new ArgumentException("Page size must be a positive power of two.", nameof(pageSize));
+
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ ///
+ /// Rents a page-aligned buffer whose is at least
+ /// (rounded up to a whole page). Reuses a free buffer of the same
+ /// bucket if available, else allocates a new POH-pinned array.
+ ///
+ public TapeWriteBuffer Rent(int minimumCapacity)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(minimumCapacity);
+ int bucket = RoundUpToPage(minimumCapacity);
+
+ lock (_lock)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_free.TryGetValue(bucket, out Stack? stack) && stack.Count > 0)
+ return stack.Pop();
+ }
+
+ // Allocate outside the lock: POH allocation can be relatively expensive.
+ return Allocate(bucket);
+ }
+
+ /// Returns a previously rented buffer for reuse. Buffers from a disposed pool are dropped (GC-reclaimed).
+ public void Return(TapeWriteBuffer buffer)
+ {
+ ArgumentNullException.ThrowIfNull(buffer);
+ if (!ReferenceEquals(buffer.Owner, this))
+ throw new ArgumentException("Buffer was not rented from this pool.", nameof(buffer));
+
+ lock (_lock)
+ {
+ if (_disposed)
+ return; // let the GC reclaim the POH array
+
+ if (!_free.TryGetValue(buffer.Capacity, out Stack? stack))
+ _free[buffer.Capacity] = stack = new Stack();
+ stack.Push(buffer);
+ }
+ }
+
+ private TapeWriteBuffer Allocate(int bucket)
+ {
+ // Over-allocate by one page so a page-aligned window of `bucket` bytes always fits regardless of
+ // where the POH placed the array's first element.
+ byte[] array = GC.AllocateArray(bucket + PageSize, pinned: true);
+ int offset = ComputeAlignedOffset(array, PageSize);
+ _logger.LogTrace("TapeWriteBufferPool: allocated POH buffer bucket={Bucket} offset={Offset}", bucket, offset);
+ return new TapeWriteBuffer(array, offset, bucket, this);
+ }
+
+ // Reads the (stable, since POH) data address and returns the offset to the next page boundary.
+ private static unsafe int ComputeAlignedOffset(byte[] array, int pageSize)
+ {
+ ref byte r0 = ref MemoryMarshal.GetArrayDataReference(array);
+ nint addr = (nint)Unsafe.AsPointer(ref r0);
+ int misalign = (int)(addr & (pageSize - 1));
+ return misalign == 0 ? 0 : pageSize - misalign;
+ }
+
+ private int RoundUpToPage(int n) => (n + PageSize - 1) & ~(PageSize - 1);
+
+ public void Dispose()
+ {
+ lock (_lock)
+ {
+ _disposed = true;
+ _free.Clear(); // POH arrays are reclaimed by the GC once unreferenced
+ }
+ }
+}
From 802b1631ef53dc84f2f4d0bcf90622c616bb5cbf Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Sat, 8 Aug 2026 04:34:14 +0200
Subject: [PATCH 08/37] Update write packer tests to integrate aligned buffer.
---
.../TapeFilePacker/PooledTestBuffers.cs | 59 ++++++++++++
.../TapeFilePacker/TapeWriteBackendTests.cs | 96 +++++++++----------
2 files changed, 105 insertions(+), 50 deletions(-)
create mode 100644 TapeLibNET.Tests/TapeFilePacker/PooledTestBuffers.cs
diff --git a/TapeLibNET.Tests/TapeFilePacker/PooledTestBuffers.cs b/TapeLibNET.Tests/TapeFilePacker/PooledTestBuffers.cs
new file mode 100644
index 0000000..3edc296
--- /dev/null
+++ b/TapeLibNET.Tests/TapeFilePacker/PooledTestBuffers.cs
@@ -0,0 +1,59 @@
+namespace TapeLibNET.Tests.TapeFilePacker;
+
+///
+/// Test helper that owns a and hands out page-aligned
+/// instances, tracking every rental so they are all returned (and the
+/// pool disposed) when the fixture is disposed.
+///
+/// Fixture-scoped rather than per-buffer on purpose: in these tests a buffer's OWNERSHIP transfers to
+/// the backend across StartWriting / AwaitCompletion, and assertions compare the raw
+/// reference (Assert.Same). A per-buffer using wrapper would
+/// both obscure that identity and race the ownership hand-off. Centralizing rent/return here keeps the
+/// tests reading like the old byte[] versions while guaranteeing no buffer is leaked.
+///
+///
+internal sealed class PooledTestBuffers : IDisposable
+{
+ private readonly TapeWriteBufferPool _pool = new();
+ private readonly List _live = [];
+
+ /// Block size used to translate "block counts" into byte lengths.
+ public uint BlockSize { get; }
+
+ public PooledTestBuffers(uint blockSize)
+ {
+ ArgumentOutOfRangeException.ThrowIfZero(blockSize);
+
+ BlockSize = blockSize;
+ }
+
+ /// Byte length of whole blocks.
+ public int Bytes(int blocks) => blocks * (int)BlockSize;
+
+ ///
+ /// Rents a page-aligned buffer large enough for blocks and fills its usable
+ /// window with . The buffer is tracked and returned automatically on dispose.
+ ///
+ public TapeWriteBuffer Make(int blocks, byte fill)
+ {
+ int len = Bytes(blocks);
+ TapeWriteBuffer buf = _pool.Rent(len);
+ buf.Data(len).Fill(fill);
+ _live.Add(buf);
+ return buf;
+ }
+
+ /// Snapshot of the first bytes of 's window.
+ public static byte[] Content(TapeWriteBuffer buf, int length) => buf.Data(length).ToArray();
+
+ public void Dispose()
+ {
+ // Return everything we handed out, then drop the pool (POH arrays are GC-reclaimed).
+ foreach (TapeWriteBuffer b in _live)
+ {
+ try { b.Return(); } catch { /* ignore double-return in edge-case tests */ }
+ }
+ _live.Clear();
+ _pool.Dispose();
+ }
+}
diff --git a/TapeLibNET.Tests/TapeFilePacker/TapeWriteBackendTests.cs b/TapeLibNET.Tests/TapeFilePacker/TapeWriteBackendTests.cs
index b792838..20a6d0a 100644
--- a/TapeLibNET.Tests/TapeFilePacker/TapeWriteBackendTests.cs
+++ b/TapeLibNET.Tests/TapeFilePacker/TapeWriteBackendTests.cs
@@ -7,16 +7,26 @@ namespace TapeLibNET.Tests.TapeFilePacker;
/// Uses which exercises the same
/// machinery as production but
/// records bytes in memory and supports scripted EOM / hard-error injection.
+///
+/// Buffers are page-aligned instances handed out by
+/// , which owns the pool and returns every rental on dispose.
+/// The fixture implements so xUnit tears it down per test.
+///
///
-public class TapeWriteBackendTests
+public class TapeWriteBackendTests : IDisposable
{
private const uint BlockSize = 512;
- private static byte[] MakeBuffer(int blocks, byte fill)
+ private readonly PooledTestBuffers _bufs = new(BlockSize);
+
+ // Convenience: byte length of N blocks, and a content snapshot of a buffer window.
+ private int Bytes(int blocks) => _bufs.Bytes(blocks);
+ private static byte[] Content(TapeWriteBuffer buf, int length) => PooledTestBuffers.Content(buf, length);
+
+ public void Dispose()
{
- var b = new byte[blocks * (int)BlockSize];
- Array.Fill(b, fill);
- return b;
+ _bufs.Dispose();
+ GC.SuppressFinalize(this);
}
#region *** Basic round-trip ***
@@ -25,9 +35,8 @@ private static byte[] MakeBuffer(int blocks, byte fill)
public void Backend_StartAwait_RoundtripsBytes()
{
using var backend = new MemoryTapeWriteBackend(BlockSize);
- var buf = MakeBuffer(4, 0xAB);
-
- backend.StartWriting(buf, buf.Length);
+ var buf = _bufs.Make(4, 0xAB);
+ backend.StartWriting(buf, Bytes(4));
var (result, returned) = backend.AwaitCompletion();
Assert.Equal(4, result.BlocksWritten);
@@ -38,17 +47,16 @@ public void Backend_StartAwait_RoundtripsBytes()
var written = backend.WrittenBuffers;
Assert.Single(written);
- Assert.Equal(buf, written[0]);
+ Assert.Equal(Content(buf, Bytes(4)), written[0]);
}
[Fact]
public void Backend_AlignsValidBytesDownToBlockBoundary()
{
using var backend = new MemoryTapeWriteBackend(BlockSize);
- var buf = MakeBuffer(3, 0x11);
-
+ var buf = _bufs.Make(3, 0x11);
// Hand off 5 fewer bytes than block-aligned; should round down to 2 full blocks.
- backend.StartWriting(buf, buf.Length - 5);
+ backend.StartWriting(buf, Bytes(3) - 5);
var (result, _) = backend.AwaitCompletion();
Assert.Equal(2, result.BlocksWritten);
@@ -58,11 +66,10 @@ public void Backend_AlignsValidBytesDownToBlockBoundary()
public void Backend_MultipleSequentialWrites_PreserveOrder()
{
using var backend = new MemoryTapeWriteBackend(BlockSize);
-
for (byte i = 1; i <= 5; i++)
{
- var buf = MakeBuffer(2, i);
- backend.StartWriting(buf, buf.Length);
+ var buf = _bufs.Make(2, i);
+ backend.StartWriting(buf, Bytes(2));
backend.AwaitCompletion();
}
@@ -82,19 +89,17 @@ public void Backend_StartWriting_BlocksUntilPreviousCompletes()
{
using var backend = new MemoryTapeWriteBackend(BlockSize);
backend.SetPerWriteDelay(TimeSpan.FromMilliseconds(150));
-
- var buf1 = MakeBuffer(2, 0x01);
- var buf2 = MakeBuffer(2, 0x02);
+ var buf1 = _bufs.Make(2, 0x01);
+ var buf2 = _bufs.Make(2, 0x02);
var sw = System.Diagnostics.Stopwatch.StartNew();
- backend.StartWriting(buf1, buf1.Length);
+ backend.StartWriting(buf1, Bytes(2));
// The second StartWriting must wait for the first to finish (~150ms).
- backend.StartWriting(buf2, buf2.Length);
+ backend.StartWriting(buf2, Bytes(2));
sw.Stop();
Assert.True(sw.ElapsedMilliseconds >= 100,
$"expected blocking ~150ms, observed {sw.ElapsedMilliseconds}ms");
-
backend.AwaitCompletion();
}
@@ -103,12 +108,10 @@ public void Backend_PollStatus_ReportsBusyThenIdle()
{
using var backend = new MemoryTapeWriteBackend(BlockSize);
backend.SetPerWriteDelay(TimeSpan.FromMilliseconds(200));
-
Assert.Equal(WriteBackendStatus.Idle, backend.PollStatus());
- var buf = MakeBuffer(1, 0x77);
- backend.StartWriting(buf, buf.Length);
-
+ var buf = _bufs.Make(1, 0x77);
+ backend.StartWriting(buf, Bytes(1));
// Should be busy almost immediately.
Assert.Equal(WriteBackendStatus.Busy, backend.PollStatus());
@@ -120,16 +123,15 @@ public void Backend_PollStatus_ReportsBusyThenIdle()
public void Backend_AwaitCompletion_IsIdempotent()
{
using var backend = new MemoryTapeWriteBackend(BlockSize);
- var buf = MakeBuffer(1, 0x42);
+ var buf = _bufs.Make(1, 0x42);
+ backend.StartWriting(buf, Bytes(1));
- backend.StartWriting(buf, buf.Length);
var (r1, b1) = backend.AwaitCompletion();
var (r2, b2) = backend.AwaitCompletion();
var (r3, b3) = backend.AwaitCompletion();
Assert.Equal(1, r1.BlocksWritten);
Assert.Same(buf, b1);
-
Assert.Equal(0, r2.BlocksWritten);
Assert.Null(b2);
Assert.Equal(0, r3.BlocksWritten);
@@ -140,8 +142,8 @@ public void Backend_AwaitCompletion_IsIdempotent()
public void Backend_AwaitCompletion_WithNothingInFlight_ReturnsEmpty()
{
using var backend = new MemoryTapeWriteBackend(BlockSize);
-
var (r, b) = backend.AwaitCompletion();
+
Assert.Equal(0, r.BlocksWritten);
Assert.Null(b);
Assert.Null(r.Exception);
@@ -157,9 +159,8 @@ public void Backend_ScriptedEom_ReportsPartialAcceptance()
{
using var backend = new MemoryTapeWriteBackend(BlockSize);
backend.ScriptEomAfterBlocks(3); // accept 3 full blocks, then EOM
-
- var buf = MakeBuffer(5, 0xCC);
- backend.StartWriting(buf, buf.Length);
+ var buf = _bufs.Make(5, 0xCC);
+ backend.StartWriting(buf, Bytes(5));
var (result, _) = backend.AwaitCompletion();
Assert.Equal(3, result.BlocksWritten);
@@ -173,9 +174,8 @@ public void Backend_ScriptedEom_AtBlockZero_ReportsZeroAccepted()
{
using var backend = new MemoryTapeWriteBackend(BlockSize);
backend.ScriptEomAfterBlocks(0);
-
- var buf = MakeBuffer(4, 0xDD);
- backend.StartWriting(buf, buf.Length);
+ var buf = _bufs.Make(4, 0xDD);
+ backend.StartWriting(buf, Bytes(4));
var (result, returned) = backend.AwaitCompletion();
Assert.Equal(0, result.BlocksWritten);
@@ -192,9 +192,8 @@ public void Backend_ScriptedHardError_SurfacesException()
{
using var backend = new MemoryTapeWriteBackend(BlockSize);
backend.ScriptHardErrorAfterBlocks(2, "boom");
-
- var buf = MakeBuffer(5, 0xEE);
- backend.StartWriting(buf, buf.Length);
+ var buf = _bufs.Make(5, 0xEE);
+ backend.StartWriting(buf, Bytes(5));
var (result, returned) = backend.AwaitCompletion();
Assert.Equal(2, result.BlocksWritten);
@@ -211,16 +210,15 @@ public void Backend_HardError_DoesNotPoisonBackend()
// is the high-layer's policy decision.
using var backend = new MemoryTapeWriteBackend(BlockSize);
backend.ScriptHardErrorAfterBlocks(1);
-
- var bufA = MakeBuffer(2, 0xA1);
- backend.StartWriting(bufA, bufA.Length);
+ var bufA = _bufs.Make(2, 0xA1);
+ backend.StartWriting(bufA, Bytes(2));
var (rA, _) = backend.AwaitCompletion();
Assert.NotNull(rA.Exception);
// Subsequent write proceeds: scripted error fires only once because
// alreadyWritten > errorAfter after the first call.
- var bufB = MakeBuffer(2, 0xB2);
- backend.StartWriting(bufB, bufB.Length);
+ var bufB = _bufs.Make(2, 0xB2);
+ backend.StartWriting(bufB, Bytes(2));
var (rB, _) = backend.AwaitCompletion();
Assert.Null(rB.Exception);
Assert.Equal(2, rB.BlocksWritten);
@@ -235,12 +233,11 @@ public void Backend_Dispose_DrainsInFlightWrite()
{
var backend = new MemoryTapeWriteBackend(BlockSize);
backend.SetPerWriteDelay(TimeSpan.FromMilliseconds(100));
-
- var buf = MakeBuffer(1, 0x55);
- backend.StartWriting(buf, buf.Length);
-
+ var buf = _bufs.Make(1, 0x55);
+ backend.StartWriting(buf, Bytes(1));
// Should block until the in-flight write completes; total blocks must be recorded.
backend.Dispose();
+
Assert.Equal(1, backend.TotalBlocksWritten);
}
@@ -249,9 +246,8 @@ public void Backend_StartWriting_AfterDispose_Throws()
{
var backend = new MemoryTapeWriteBackend(BlockSize);
backend.Dispose();
-
- var buf = MakeBuffer(1, 0);
- Assert.Throws(() => backend.StartWriting(buf, buf.Length));
+ var buf = _bufs.Make(1, 0);
+ Assert.Throws(() => backend.StartWriting(buf, Bytes(1)));
}
#endregion
From 80b0de858ba84476a064915fb0ad277e81bc7d6d Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Sat, 8 Aug 2026 04:52:19 +0200
Subject: [PATCH 09/37] Update the design doc Design-RemainingAndEw.md to
document aligned buffer mechanism for accelerating writes.
---
docs/Design-RemainingAndEw.md | 45 +++++++++++++++++++++++++++++++----
1 file changed, 40 insertions(+), 5 deletions(-)
diff --git a/docs/Design-RemainingAndEw.md b/docs/Design-RemainingAndEw.md
index b7241bc..16434d1 100644
--- a/docs/Design-RemainingAndEw.md
+++ b/docs/Design-RemainingAndEw.md
@@ -46,11 +46,17 @@ Design specification lives inline in `TapeDriveWin32Backend.lto-direct.cs`. Key
port/miniport IOCTL the class driver does not forward on a `\\.\TAPEn` handle (returns
`ERROR_INVALID_FUNCTION`). The storage-property query **is** forwarded and yields
`MaximumTransferLength` / `MaximumPhysicalPages` / `AlignmentMask`.
-- **Page-aligned scratch + SG budget** — the miniport locks the caller's buffer into a scatter/gather list
- bounded by `MaximumPhysicalPages`. A pinned managed array is only 8-byte aligned, so a 64 KB payload spans
- 17 physical pages — the common adapter SG limit — hence unaligned SPTD writes fail above 64 KB. Routing the
- payload through a reusable page-aligned native buffer (`NativeMemory.AlignedAlloc`, page size from
- `Environment.SystemPageSize`) lifts the limit to `MaximumPhysicalPages × pageSize`.
+- - **Adaptive alignment + SG budget** — the miniport locks the caller's buffer into a scatter/gather list
+ bounded by MaximumPhysicalPages. A pinned managed array is only 8-byte aligned, so a 64 KB payload spans 17
+ physical pages — the common adapter SG limit — hence unaligned SPTD writes fail above 64 KB.
+ `SendScsiCommandDirect` therefore pins the caller's buffer and inspects its address: an **already
+ page-aligned** payload is DMA'd **directly with no copy**; a misaligned one is copied once into a reusable
+ page-aligned native scratch (`NativeMemory.AlignedAlloc`, page size from `Environment.SystemPageSize`).
+ Either way the driver receives a page-aligned DataBuffer, so a full-budget chunk occupies at most
+ MaximumPhysicalPages fragments (limit = MaximumPhysicalPages × pageSize) — no per-chunk headroom hack needed.
+ The alignment is **self-describing** from the pinned pointer, so no "isAligned" flag crosses the API boundary.
+ The zero-copy fast path is supplied by the packer via page-aligned buffers (see **Part 1A**);
+ the scratch-copy path remains the correct fallback for any misaligned caller.
- **Automatic chunking** — a single SRB cannot exceed the adapter ceiling (~1 MB on the test rig despite the
drive's 1 MB max block). `WriteFile` reaches multi-MB transfers because `tape.sys` splits into adapter-sized
SRBs internally; `ScsiWriteDirect` replicates that by chunking a large fixed-block write into back-to-back
@@ -66,6 +72,34 @@ Design specification lives inline in `TapeDriveWin32Backend.lto-direct.cs`. Key
---
+### Part 1A — Page-aligned write buffers (TapeWriteBuffer) [DONE]
+
+The SPTD path can DMA straight from the caller's buffer only when that buffer is page-aligned; otherwise it pays a per-chunk copy into the aligned scratch. Measurement showed the SPTD writer running slightly slower than a plain `WriteFile`, traced to two hot-path costs: (1) the per-write alignment **copy**, and (2) a per-chunk **heap allocation** of the SPTD control block. Both are now eliminated, closing the gap while preserving EW/PEW sensing. The chunking floor (N × `DeviceIoControl` per multi-MB write) is inherent to sensing and remains.
+
+#### TapeWriteBuffer / TapeWriteBufferPool (new file TapeWriteBuffer.cs)
+
+A pooled, page-aligned write buffer and its pool. Key design points:
+- **Pinned Object Heap, not NativeMemory.AlignedAlloc** — `GC.AllocateArray(size, pinned: true)` returns a **real `byte[]`** that never moves and is GC-reclaimed (no manual free, no leak on exception paths), so the packer keeps its `byte[]`-centric copy/clear logic. `AlignedAlloc` was rejected because it yields non-managed memory (loses `Buffer.BlockCopy`/`Array.Clear`) and needs manual `try/finally` freeing.
+- **Over-allocate by one page, expose an aligned window** — a POH array's element 0 is not page-aligned, so the pool allocates `bucket + pageSize`, computes the offset to the next page boundary once (stable for life, since POH never moves), and exposes a page-aligned window of `Capacity` bytes. The window's internal offset is **private** — callers address it with 0-based positions.
+- **Encapsulated mutation API** — `CopyFrom`, `Clear`, `CopyRegionTo`, `Data(length)`, `Return()`. The offset never leaks into the packer; there is no `Fill`-struct laundry. `Data(length)` yields the transient, already-aligned span for the write hand-off.
+- **Multiset pool, bucketed by page-rounded capacity** — supports several live rentals of one size (the packer's double-buffering) as a stack per bucket. Rent/Return are cheap and thread-safe; a disposed pool drops references (POH reclaimed by GC).
+
+#### SCSI-side adoption (TapeDriveWin32Backend.lto-direct.cs)
+
+- **Auto-detect, no flag** — `SendScsiCommandDirect` reads the pinned pointer's alignment and chooses zero-copy vs. scratch-copy itself; the separate `SendScsiCommandDirectAligned` transport and the `useAligned`/`bufferIsAligned` parameter are **gone**. The SCSI signature stays a clean `byte[]+offset+count` — `TapeWriteBuffer` never appears in the SCSI layer, fully preserving the WriteDirect→SCSI boundary.
+- **stackalloc control block** — the ~76-byte SPTD+sense control buffer is `stackalloc`'d per command instead of `new byte[]`, removing the per-chunk GC allocation.
+- **Full SRB budget** — because the driver always receives a page-aligned DataBuffer, the former one-page headroom reduction is dropped (`effectiveMax = MaxScsiDirectTransfer`).
+
+#### TapeFilePacker adoption (TapeFileWritePacker.cs)
+
+- **Fill buffers are pooled TapeWriteBuffers** — the packer holds a single `TapeWriteBuffer _fillBuffer`; the old `ArrayPool` path is removed. `WriteFromOpenFile`, the zero-pad in `Flush`, and the leftover-carry in `DoFlushFillBuffer` use `CopyFrom` / `Clear` / `CopyRegionTo`. Page alignment means every hand-off reaches the SPTD zero-copy fast path.
+- **Session-scoped pool ownership** — the packer takes an optional `TapeWriteBufferPool`: supplied → shared and **not** disposed (production: one per `TapeStreamManager`/session, amortized across any number of packers and released at session end — POH-friendly); omitted → a private pool it owns and disposes (unit tests need no wiring). A drive-lifetime pool was rejected: at steady state only ~2 buffers churn per session, so a session-scoped pool captures essentially all the benefit while avoiding pinned memory held across idle stretches.
+- **Backend contract widened to the buffer type** — `ITapeWriteBackend.StartWriting(TapeWriteBuffer, int)` and `AwaitCompletion() → (WriteResult, TapeWriteBuffer?)`, so ownership (and buffer identity, for `Assert.Same` in tests) round-trips as the raw handle; `TapeWriteSink` likewise takes `TapeWriteBuffer`.
+
+**Caveat.** The zero-copy fast path requires the block size to be a page multiple so every chunk start stays aligned; LTO's power-of-two sizes (16 KB … 1 MB) all satisfy this, and a non-conforming size simply falls back to the scratch copy. **Expectation:** removing the copy + per-chunk alloc closes most of the gap, landing SPTD *at* `WriteFile` speed — the residual N× `DeviceIoControl` chunking is the price of EW/PEW sensing.
+
+---
+
## Part 2 — Early warning: physical and logical [DONE]
Two layers of early warning coexist. The **physical** EW is what the drive reports; the **logical** EW is
@@ -254,6 +288,7 @@ Runtime (every session):
| `Virtual/VirtualTapeEwProfile.cs` | Opt-in emulation profile (EW zone + reported-remaining model; `Lto4Like`/`FromCalibration`) — Part 4.1. |
| `Virtual/VirtualTapeMedia.EW.cs` | Per-cartridge EW state: `TrueRemaining`, reported `Remaining`, `IsInEarlyWarningZone` — Part 4.1. |
| `Virtual/VirtualTapeDriveBackend.EW.cs` | Backend EW config/surface: `EmulatedEarlyWarning`, mechanism overrides, `ew` in `Write` — Part 4.1. |
+| `TapeWriteBuffer.cs` | Pooled page-aligned POH write buffer + pool; SPTD zero-copy fast path (Part 1A). |
---
From d6224aa60fe72b41d78660736117a56159a7dad4 Mon Sep 17 00:00:00 2001
From: Alex K
Date: Sun, 9 Aug 2026 16:05:06 +0200
Subject: [PATCH 10/37] Update calibration options to follow the sample count
preference.
---
.../CalibrationAndLogicalEwTests.cs | 20 +-
.../Services/ServiceCalibrationTests.cs | 12 +-
.../TapeFilePacker/PooledTestBuffers.cs | 2 +-
.../ServiceOperationProgressHandler.cs | 11 +-
TapeLibNET/TapeCalibrationOptions.cs | 70 ++++++
TapeLibNET/TapeCalibrator.cs | 226 +++++++++---------
TapeLibNET/TapeWriteBuffer.cs | 8 +
TapeWinNET/CalibrationWindow.xaml | 14 +-
.../Controls/CalibrationCurveControl.xaml.cs | 6 +-
9 files changed, 223 insertions(+), 146 deletions(-)
create mode 100644 TapeLibNET/TapeCalibrationOptions.cs
diff --git a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
index f59b64b..ccb7afd 100644
--- a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
+++ b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
@@ -58,8 +58,8 @@ public void CalibrationRun_ProducesUsableMonotonicCurve_WithEwLandmark()
Options = new TapeCalibrationOptions
{
SampleCount = 40,
- MinSampleInterval = 1L * 1024 * 1024,
- ChunkBytesTarget = 1L * 1024 * 1024,
+ //MinSampleInterval = 1L * 1024 * 1024,
+ //ChunkBytesTarget = 1L * 1024 * 1024,
},
};
@@ -103,8 +103,8 @@ public void CalibrationRun_RestoresPriorReserveAndCalibrations()
Options = new TapeCalibrationOptions
{
SampleCount = 20,
- MinSampleInterval = 2L * 1024 * 1024,
- ChunkBytesTarget = 1L * 1024 * 1024,
+ //MinSampleInterval = 2L * 1024 * 1024,
+ //ChunkBytesTarget = 1L * 1024 * 1024,
},
};
Assert.NotNull(calibrator.Run());
@@ -133,8 +133,8 @@ public void CalibrationRun_WithOverreport_CapturesBothBomAndEomAnchors(
Options = new TapeCalibrationOptions
{
SampleCount = 40,
- MinSampleInterval = 1L * 1024 * 1024,
- ChunkBytesTarget = 1L * 1024 * 1024,
+ //MinSampleInterval = 1L * 1024 * 1024,
+ //ChunkBytesTarget = 1L * 1024 * 1024,
},
};
@@ -175,8 +175,8 @@ public void CalibrationJson_RoundTrips_AndRejectsUnknownFormat()
Options = new TapeCalibrationOptions
{
SampleCount = 20,
- MinSampleInterval = 2L * 1024 * 1024,
- ChunkBytesTarget = 1L * 1024 * 1024,
+ //MinSampleInterval = 2L * 1024 * 1024,
+ //ChunkBytesTarget = 1L * 1024 * 1024,
},
};
ITapeCalibration? cal = calibrator.Run();
@@ -370,8 +370,8 @@ public void EstimateActualRemaining_TracksTrueRemaining_AcrossRegimes()
Options = new TapeCalibrationOptions
{
SampleCount = 60,
- MinSampleInterval = 512L * 1024,
- ChunkBytesTarget = 1L * 1024 * 1024,
+ //MinSampleInterval = 512L * 1024,
+ //ChunkBytesTarget = 1L * 1024 * 1024,
},
}.Run();
Assert.NotNull(cal);
diff --git a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
index 18e9f83..62a9fe8 100644
--- a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
+++ b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
@@ -43,8 +43,8 @@ public async Task ExecuteCalibrateAsync_ReturnsCalibrationAndLogsSummary()
Options: new TapeCalibrationOptions
{
SampleCount = 20,
- MinSampleInterval = 1L * MB,
- ChunkBytesTarget = 1L * MB,
+ //MinSampleInterval = 1L * MB,
+ //ChunkBytesTarget = 1L * MB,
}));
Assert.True(result.Success);
@@ -78,8 +78,8 @@ public async Task ExecuteCalibrateAsync_HonorsAbortRequest()
Options: new TapeCalibrationOptions
{
SampleCount = 16,
- MinSampleInterval = 1L * MB,
- ChunkBytesTarget = 1L * MB,
+ //MinSampleInterval = 1L * MB,
+ //ChunkBytesTarget = 1L * MB,
})
{
Cancellation = cts.Token,
@@ -112,8 +112,8 @@ public async Task ExecuteCalibrateAsync_WithCustomOverreport_ExposesBothOverrepo
Options: new TapeCalibrationOptions
{
SampleCount = 20,
- MinSampleInterval = 1L * MB,
- ChunkBytesTarget = 1L * MB,
+ //MinSampleInterval = 1L * MB,
+ //ChunkBytesTarget = 1L * MB,
}));
Assert.True(result.Success);
diff --git a/TapeLibNET.Tests/TapeFilePacker/PooledTestBuffers.cs b/TapeLibNET.Tests/TapeFilePacker/PooledTestBuffers.cs
index 3edc296..4fcb530 100644
--- a/TapeLibNET.Tests/TapeFilePacker/PooledTestBuffers.cs
+++ b/TapeLibNET.Tests/TapeFilePacker/PooledTestBuffers.cs
@@ -38,7 +38,7 @@ public TapeWriteBuffer Make(int blocks, byte fill)
{
int len = Bytes(blocks);
TapeWriteBuffer buf = _pool.Rent(len);
- buf.Data(len).Fill(fill);
+ buf.Data().Fill(fill);
_live.Add(buf);
return buf;
}
diff --git a/TapeLibNET/Services/ServiceOperationProgressHandler.cs b/TapeLibNET/Services/ServiceOperationProgressHandler.cs
index 3b5f589..7adcb29 100644
--- a/TapeLibNET/Services/ServiceOperationProgressHandler.cs
+++ b/TapeLibNET/Services/ServiceOperationProgressHandler.cs
@@ -337,15 +337,14 @@ public class ServiceCalibrateProgressHandler(
/// The live calibrator driving the operation.
protected readonly TapeCalibrator Calibrator = calibrator;
- private readonly int _estimatedChunkBytes = checked((int)Math.Max(1L, calibrator.Options.ChunkBytesTarget));
+ private readonly int _estimatedChunkSize = calibrator.Options.ResolveFor(calibrator.Drive).ChunkSize;
private bool _abortLogged;
private bool _ewLogged;
/// Estimated number of chunks needed to traverse the medium.
- public int FilesTotal { get; private set; } =
+ public int FilesTotal =>
capacityReported > 0
- ? (int)Math.Min(int.MaxValue, (capacityReported + Math.Max(1L, calibrator.Options.ChunkBytesTarget) - 1L)
- / Math.Max(1L, calibrator.Options.ChunkBytesTarget))
+ ? (int)Math.Min(int.MaxValue, (capacityReported + _estimatedChunkSize - 1L) / _estimatedChunkSize)
: 0;
/// Estimated media capacity reported by the drive at BOT.
@@ -399,8 +398,8 @@ public void Report(TapeCalibrationProgress progress)
ThrowIfAbortRequested();
BytesProcessed = Math.Max(0L, progress.BytesWritten);
- FilesProcessed = _estimatedChunkBytes > 0
- ? (int)Math.Min(int.MaxValue, (BytesProcessed + _estimatedChunkBytes - 1L) / _estimatedChunkBytes)
+ FilesProcessed = _estimatedChunkSize > 0
+ ? (int)Math.Min(int.MaxValue, (BytesProcessed + _estimatedChunkSize - 1L) / _estimatedChunkSize)
: 0;
FilesSucceeded = FilesProcessed;
CurrentPhase = FormatPhase(progress.Phase);
diff --git a/TapeLibNET/TapeCalibrationOptions.cs b/TapeLibNET/TapeCalibrationOptions.cs
new file mode 100644
index 0000000..f7cfb0f
--- /dev/null
+++ b/TapeLibNET/TapeCalibrationOptions.cs
@@ -0,0 +1,70 @@
+namespace TapeLibNET;
+
+///
+/// Caller intent for a calibration run. The calibrator resolves this against a specific
+/// into a concrete . Defaults target a
+/// correct, deterministic measurement: the drive's maximum block size, hardware compression off,
+/// and ~ curve points spread across the medium.
+///
+public readonly record struct TapeCalibrationOptions
+{
+ /// Approximate number of ReportedRemaining → ActualRemaining curve points to record. Default 100.
+ public int SampleCount { get; init; }
+
+ /// Payload size per WriteDirect call, in blocks. ≤ 0 falls back to .
+ public int BlocksPerChunk { get; init; }
+
+ /// Default value for .
+ public const int DefaultBlocksPerChunk = 8;
+
+ public TapeCalibrationOptions()
+ {
+ SampleCount = 100;
+ BlocksPerChunk = DefaultBlocksPerChunk;
+ }
+
+ /// Turn caller intent into a concrete, always-valid plan for this drive.
+ public TapeCalibrationPlan ResolveFor(TapeDrive drive)
+ {
+ ArgumentNullException.ThrowIfNull(drive);
+ int blocksPerChunk = BlocksPerChunk > 0 ? BlocksPerChunk : DefaultBlocksPerChunk;
+ uint blockSize = drive.MaximumBlockSize;
+ var plan = TapeCalibrationPlan.Create(SampleCount, blockSize, blocksPerChunk);
+
+ // Check if the ChunkSize isn't too coarse to reach SampleCount
+ long sampleStep = drive.ContentCapacity / plan.SampleCount;
+ if (plan.ChunkSize <= sampleStep)
+ return plan; // if yes, we're good to go
+
+ // If not, first try to reduce BlocksPerChunk
+ blocksPerChunk = (int)(sampleStep / blockSize);
+ if (blocksPerChunk <= 0)
+ {
+ // if still too coarse, reduce BlockSize to the drive's default
+ blockSize = drive.DefaultBlockSize;
+ blocksPerChunk = Math.Max(1, (int)(sampleStep / blockSize)); // we won't reduce any further
+ }
+
+ plan = TapeCalibrationPlan.Create(SampleCount, blockSize, blocksPerChunk);
+
+ return plan;
+ }
+}
+
+///
+/// Fully resolved run parameters — everything the calibrator needs to configure the drive, nothing
+/// more. Every field is concrete; is always valid (no divide-by-zero path).
+///
+public readonly record struct TapeCalibrationPlan(
+ int SampleCount,
+ uint BlockSize,
+ int BlocksPerChunk,
+ int ChunkSize)
+{
+ /// Build a plan, deriving and clamping to ≥ 1.
+ internal static TapeCalibrationPlan Create(int sampleCount, uint blockSize, int blocksPerChunk)
+ {
+ int chunkSize = checked((int)(blocksPerChunk * (long)blockSize));
+ return new TapeCalibrationPlan(Math.Max(1, sampleCount), blockSize, blocksPerChunk, chunkSize);
+ }
+}
\ No newline at end of file
diff --git a/TapeLibNET/TapeCalibrator.cs b/TapeLibNET/TapeCalibrator.cs
index 912b682..c47c766 100644
--- a/TapeLibNET/TapeCalibrator.cs
+++ b/TapeLibNET/TapeCalibrator.cs
@@ -1,38 +1,11 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
-using Microsoft.Extensions.Logging;
using Windows.Win32.Foundation;
namespace TapeLibNET;
-///
-/// Options controlling a calibration run. Defaults target a correct, deterministic measurement:
-/// the maximum block size, hardware compression off, and ~ curve points
-/// spread across the medium.
-///
-public readonly record struct TapeCalibrationOptions
-{
- /// Approximate number of ReportedRemaining → ActualRemaining curve points to record. Default 100.
- public int SampleCount { get; init; }
-
- /// Block size to use during the run, in bytes. 0 = the drive's maximum block size.
- public uint BlockSize { get; init; }
-
- /// Target payload size per WriteDirect call, in bytes (rounded down to whole blocks). Default 4 MB.
- public long ChunkBytesTarget { get; init; }
-
- /// Smallest spacing between curve samples, in bytes. Default 256 MB.
- public long MinSampleInterval { get; init; }
-
- public TapeCalibrationOptions()
- {
- SampleCount = 100;
- BlockSize = 0;
- ChunkBytesTarget = 4L * 1024 * 1024;
- MinSampleInterval = 256L * 1024 * 1024;
- }
-}
-
///
/// A progress sample emitted during a calibration run, suitable for .
///
@@ -102,25 +75,39 @@ public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder 0
- ? Math.Max(capacityReportedAtBom / Math.Max(1, Options.SampleCount), Options.MinSampleInterval)
- : Options.MinSampleInterval;
+ // --- Sample cadence: never finer than one chunk, ~SampleCount points across the medium ---
+ long sampleInterval = Math.Max(plan.ChunkSize, capacityReportedAtBom / plan.SampleCount);
m_logger.LogInformation(
"{Prefix}: Calibration start — profile '{Key}', reportedCapacityAtBom {Cap}, blockSize {Bs}, chunk {Chunk}, sampleInterval {Int}",
- LogPrefix, Drive.DriveProfileKey, capacityReportedAtBom, blockSize, chunkBytes, sampleInterval);
+ LogPrefix, Drive.DriveProfileKey, capacityReportedAtBom, blockSize, chunkSize, sampleInterval);
// --- Write to hard EOM, sampling as we go ---
var samples = new List<(long ActualWritten, long ReportedRemaining)>();
@@ -163,91 +154,92 @@ public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder= nextSample)
+ {
+ long rr = Drive.GetReportedContentRemaining();
+ samples.Add((bytesWritten, rr));
+ progress?.Report(new TapeCalibrationProgress(
+ bytesWritten, rr, Drive.GetCurrentBlock(), EarlyWarning: ewPoint is not null, EndOfMedium: false, "sampling"));
+ nextSample += sampleInterval;
+ }
}
- if (bytesWritten >= nextSample)
+ long capacityActual = bytesWritten;
+ if (capacityActual <= 0)
{
- long rr = Drive.GetReportedContentRemaining();
- samples.Add((bytesWritten, rr));
- progress?.Report(new TapeCalibrationProgress(
- bytesWritten, rr, Drive.GetCurrentBlock(), EarlyWarning: ewPoint is not null, EndOfMedium: false, "sampling"));
- nextSample += sampleInterval;
+ SetError(WIN32_ERROR.ERROR_IO_DEVICE);
+ LogErrorAsDebug("Calibration: reached EOM with zero bytes written");
+ return null;
}
- }
-
- long capacityActual = bytesWritten;
- if (capacityActual <= 0)
- {
- SetError(WIN32_ERROR.ERROR_IO_DEVICE);
- LogErrorAsDebug("Calibration: reached EOM with zero bytes written");
- return null;
- }
- TapeCalibration calibration = TapeCalibration.FromMeasurements(
- Drive.DriveProfileKey, capacityReportedAtBom, capacityActual, samples, ewPoint);
+ TapeCalibration calibration = TapeCalibration.FromMeasurements(
+ Drive.DriveProfileKey, capacityReportedAtBom, capacityActual, samples, ewPoint);
- m_logger.LogInformation(
- "{Prefix}: Calibration done — actualCapacity {Act} ({Pct:F1}% of reported at BOM), " +
- "phantomFreeAtEom {Phantom}, EW {Ew}, points {N}",
- LogPrefix, capacityActual,
- calibration.ReportedCapacityAtBom > 0 ? 100.0 * capacityActual / calibration.ReportedCapacityAtBom : 0.0,
- calibration.PhantomFreeAtEom,
- ewPoint is { } e ? $"{e.ActualWritten} bytes / RR {e.ReportedRemaining}" : "(none)",
- samples.Count);
-
- ResetError();
- return calibration;
+ m_logger.LogInformation(
+ "{Prefix}: Calibration done — actualCapacity {Act} ({Pct:F1}% of reported at BOM), " +
+ "phantomFreeAtEom {Phantom}, EW {Ew}, points {N}",
+ LogPrefix, capacityActual,
+ calibration.ReportedCapacityAtBom > 0 ? 100.0 * capacityActual / calibration.ReportedCapacityAtBom : 0.0,
+ calibration.PhantomFreeAtEom,
+ ewPoint is { } e ? $"{e.ActualWritten} bytes / RR {e.ReportedRemaining}" : "(none)",
+ samples.Count);
+ ResetError();
+ return calibration;
}
finally
{
@@ -256,8 +248,10 @@ public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder
public Span Data(int length) => _array.AsSpan(_offset, length);
+ ///
+ /// The entire (page-aligned) window, as a span. Transient — do not store; the backing array is POH so it
+ /// never moves during the write. Provided for span-based consumers;
+ /// the byte[]-based write path uses the internal / instead.
+ ///
+ ///
+ public Span Data() => Data(Capacity);
+
/// Returns this buffer to its owning pool for reuse.
public void Return() => Owner.Return(this);
}
diff --git a/TapeWinNET/CalibrationWindow.xaml b/TapeWinNET/CalibrationWindow.xaml
index fa4f490..6da6318 100644
--- a/TapeWinNET/CalibrationWindow.xaml
+++ b/TapeWinNET/CalibrationWindow.xaml
@@ -32,7 +32,7 @@
@@ -75,6 +75,12 @@
+
+
+
+
+
+
@@ -82,12 +88,12 @@
+ Margin="0,0,0,14" Grid.ColumnSpan="4" Grid.RowSpan="2"/>
+ TextWrapping="Wrap" Grid.ColumnSpan="4" Margin="0,96,0,0"/>
diff --git a/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs b/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
index 352f241..18ee8d8 100644
--- a/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
+++ b/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
@@ -4,7 +4,7 @@
using System.Windows.Media;
using System.Windows.Shapes;
-using Windows.Win32.System.SystemServices; // Helpers.BytesToStringLong
+using Windows.Win32.System.SystemServices; // Helpers.BytesToString
using TapeLibNET;
@@ -144,9 +144,9 @@ Point MapPoint(CalibrationPoint point)
_curveLine.Points = [.. calibration.Curve.Select(MapPoint)];
- ActualTopLabel.Text = Helpers.BytesToStringLong(actualMax);
+ ActualTopLabel.Text = Helpers.BytesToString(actualMax);
ActualBottomLabel.Text = "0";
- ReportedLeftLabel.Text = Helpers.BytesToStringLong(reportedMax);
+ ReportedLeftLabel.Text = Helpers.BytesToString(reportedMax);
ReportedRightLabel.Text = "EOM";
if (ewReported > 0 && calibration.EarlyWarning is { } ew)
From 19a38a3ade1803618f070d5d7a468148315847c7 Mon Sep 17 00:00:00 2001
From: Alex K
Date: Sun, 9 Aug 2026 16:56:28 +0200
Subject: [PATCH 11/37] Improvbe calibration graph view UI.
---
TapeWinNET/CalibrationWindow.xaml | 7 ++++---
TapeWinNET/Controls/CalibrationCurveControl.xaml | 4 ++--
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/TapeWinNET/CalibrationWindow.xaml b/TapeWinNET/CalibrationWindow.xaml
index 6da6318..3df43ce 100644
--- a/TapeWinNET/CalibrationWindow.xaml
+++ b/TapeWinNET/CalibrationWindow.xaml
@@ -76,7 +76,8 @@
help:HelpControlNameAttachedProperty.ControlName="Calibration curve">
-
+
+
@@ -88,12 +89,12 @@
+ Margin="0,0,0,14" Grid.ColumnSpan="5" Grid.RowSpan="2"/>
+ TextWrapping="Wrap" Grid.ColumnSpan="5" Margin="0,128,0,0" TextAlignment="Right"/>
diff --git a/TapeWinNET/Controls/CalibrationCurveControl.xaml b/TapeWinNET/Controls/CalibrationCurveControl.xaml
index 352387b..115635c 100644
--- a/TapeWinNET/Controls/CalibrationCurveControl.xaml
+++ b/TapeWinNET/Controls/CalibrationCurveControl.xaml
@@ -6,7 +6,6 @@
mc:Ignorable="d"
d:DesignHeight="160"
d:DesignWidth="420"
- MinHeight="160"
ClipToBounds="True">
@@ -20,7 +19,7 @@
Date: Sun, 9 Aug 2026 18:18:48 +0200
Subject: [PATCH 12/37] Improve calibration UI
---
.../VirtualDriveEarlyWarningTests.cs | 2 +-
TapeLibNET/Services/TapeServiceBase.EW.cs | 1 +
TapeLibNET/Services/TapeServiceBase.cs | 2 +-
TapeLibNET/TapeCalibration.cs | 6 +-
TapeLibNET/TapeCalibrator.cs | 90 ++++++++++++++-----
TapeLibNET/TapeDriveBackend.cs | 2 +-
TapeWinNET/CalibrationWindow.xaml | 32 ++++---
docs/Design-RemainingAndEw.md | 6 +-
8 files changed, 99 insertions(+), 42 deletions(-)
diff --git a/TapeLibNET.Tests/VirtualDriveEarlyWarningTests.cs b/TapeLibNET.Tests/VirtualDriveEarlyWarningTests.cs
index a4366ca..bdf18c9 100644
--- a/TapeLibNET.Tests/VirtualDriveEarlyWarningTests.cs
+++ b/TapeLibNET.Tests/VirtualDriveEarlyWarningTests.cs
@@ -170,7 +170,7 @@ public void FromCalibration_RescalesProfileToSmallVirtualCapacity()
{
// A large a-priori LTO-like profile rescaled onto the tiny virtual cartridge.
const long largeCapacity = 780L * 1024 * 1024 * 1024; // ~780 GB
- var cal = TapeCalibration.Apriori("test|profile|rev|cap=780GB", largeCapacity);
+ var cal = TapeCalibration.Apriori("test|profile|rev|780GB", largeCapacity);
var profile = VirtualTapeEwProfile.FromCalibration(cal, Capacity);
using var backend = CreateBackend(profile, report: true);
diff --git a/TapeLibNET/Services/TapeServiceBase.EW.cs b/TapeLibNET/Services/TapeServiceBase.EW.cs
index 8ce2dde..1c6ee8f 100644
--- a/TapeLibNET/Services/TapeServiceBase.EW.cs
+++ b/TapeLibNET/Services/TapeServiceBase.EW.cs
@@ -119,6 +119,7 @@ CalibrateResult MakeResult(
LogInfo($"Calibration profile: >{_drive.DriveProfileKey}<");
LogInfoSub($"Reported capacity: {Helpers.BytesToStringLong(_drive.Capacity)}");
+ OnStatusUpdate("Calibrating...");
timer.Restart();
ITapeCalibration? calibration = calibrator.Run(progressHandler);
timer.Stop();
diff --git a/TapeLibNET/Services/TapeServiceBase.cs b/TapeLibNET/Services/TapeServiceBase.cs
index 1ace4a3..6ef1b7a 100644
--- a/TapeLibNET/Services/TapeServiceBase.cs
+++ b/TapeLibNET/Services/TapeServiceBase.cs
@@ -254,7 +254,7 @@ public string RemainingAndEwStatus
///
/// The "Estimation by" figure for property panes: names the source of the remaining-capacity estimate
/// and flags an early-warning crossing. Examples: "none", "apriori", "Hardware",
- /// "Calibration …|cap=780GB", "Calibration … (⚠ EW reached)".
+ /// "Calibration …|780GB", "Calibration … (⚠ EW reached)".
///
public string RemainingEstimationSource
{
diff --git a/TapeLibNET/TapeCalibration.cs b/TapeLibNET/TapeCalibration.cs
index 5394cd5..14cbb14 100644
--- a/TapeLibNET/TapeCalibration.cs
+++ b/TapeLibNET/TapeCalibration.cs
@@ -29,7 +29,7 @@ public interface ITapeCalibration
///
/// Stable key identifying the drive+media profile this calibration applies to
- /// (vendor|product|revision|cap=NNNGB). Compared against .
+ /// (vendor|product|revision|NNNGB). Compared against .
///
string ProfileKey { get; }
@@ -326,11 +326,11 @@ public long TranslateActualToReported(long actualRemaining)
///
/// Produces a profile key identical in form to :
- /// vendor|product|revision|cap=NNNGB. Provided as a convenience; matching relies on
+ /// vendor|product|revision|NNNGB. Provided as a convenience; matching relies on
/// exact string equality against .
///
public static string MakeProfileKey(string vendor, string product, string revision, long capacityBytes)
- => $"{vendor}|{product}|{revision}|cap={CapacityBucket(capacityBytes)}";
+ => $"{vendor}|{product}|{revision}|{CapacityBucket(capacityBytes)}";
///
/// Coarse capacity bucket (2 significant figures) shared with the backend, so a key made here lines
diff --git a/TapeLibNET/TapeCalibrator.cs b/TapeLibNET/TapeCalibrator.cs
index c47c766..00bf95e 100644
--- a/TapeLibNET/TapeCalibrator.cs
+++ b/TapeLibNET/TapeCalibrator.cs
@@ -47,6 +47,17 @@ public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder
/// Executes the calibration. DESTRUCTIVE: overwrites the medium from BOT of the content partition.
/// Leaves the tape at (or just past) EOM; the caller typically reformats/reloads afterward.
@@ -108,29 +119,61 @@ public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder
/// Backends may override to add density or other discriminators.
///
- public virtual string ProfileKey => $"{Vendor}|{Product}|{Revision}|cap={TapeCalibration.CapacityBucket(Capacity)}";
+ public virtual string ProfileKey => $"{Vendor}|{Product}|{Revision}|{TapeCalibration.CapacityBucket(Capacity)}";
///
/// Rounds a native capacity to a coarse bucket string (2 significant figures) so that
diff --git a/TapeWinNET/CalibrationWindow.xaml b/TapeWinNET/CalibrationWindow.xaml
index 3df43ce..ad85cc3 100644
--- a/TapeWinNET/CalibrationWindow.xaml
+++ b/TapeWinNET/CalibrationWindow.xaml
@@ -75,35 +75,43 @@
-
-
-
-
-
-
-
+ Calibration="{Binding Calibration}"/>
+ TextWrapping="Wrap"
+ Margin="0,6,0,0"
+ TextAlignment="Right"/>
+
-
+ TextWrapping="Wrap">
+
+
+
+
+
+ public bool EjectWhenDone
+ {
+ get => _ejectWhenDone;
+ set => SetProperty(ref _ejectWhenDone, value);
+ }
+
public string Vendor => string.IsNullOrWhiteSpace(_tapeService.DeviceVendor) ? "Unknown" : _tapeService.DeviceVendor;
public string Product => string.IsNullOrWhiteSpace(_tapeService.DeviceProduct) ? "Unknown" : _tapeService.DeviceProduct;
public string Revision => string.IsNullOrWhiteSpace(_tapeService.DeviceRevision) ? "Unknown" : _tapeService.DeviceRevision;
@@ -159,7 +167,7 @@ public async Task RunAsync()
{
Result = await _tapeService.ExecuteCalibrateAsync(
new CalibrateRequest(
- EjectWhenDone: false,
+ EjectWhenDone: EjectWhenDone,
Options: new TapeCalibrationOptions())
{
Cancellation = _abortCts.Token,
diff --git a/TapeWinNET/ViewModels/MainViewModel.Calibration.cs b/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
index dfd0f2d..896d255 100644
--- a/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
@@ -133,6 +133,13 @@ private async Task ExecuteCalibrationAsync(CalibrationViewModel viewModel)
{
var operationResult = await viewModel.RunAsync();
+ // Calibration overwrites the media from the moment PrepareMedia succeeds, regardless
+ // of the eventual outcome, so the TOC the service (and this tree/view) previously held
+ // is now stale. Drop back to the "media loaded, no TOC" (or, if EjectWhenDone ejected
+ // the media, "no media") state to match reality.
+ if (_tapeService.TOC == null)
+ UpdateTreeForDriveOnly(_tapeService.DriveNumber);
+
if (operationResult is { HasFailed: true })
{
SimpleBox.Show("Calibration failed. See log for details.", "Calibration Failed",
From e82854c3b8de47ec4587469a219ff2cff65256f0 Mon Sep 17 00:00:00 2001
From: Alex K
Date: Tue, 11 Aug 2026 02:35:17 +0200
Subject: [PATCH 15/37] Ensure reconciliation of virtual drive caps with
virtual media parameters.
---
TapeLibNET/TapeDrive.cs | 4 +-
TapeLibNET/Virtual/VirtualTapeDriveBackend.cs | 58 +++++++++++++++----
TapeLibNET/Virtual/VirtualTapeMedia.cs | 1 +
3 files changed, 50 insertions(+), 13 deletions(-)
diff --git a/TapeLibNET/TapeDrive.cs b/TapeLibNET/TapeDrive.cs
index 4a38b02..126e4e6 100644
--- a/TapeLibNET/TapeDrive.cs
+++ b/TapeLibNET/TapeDrive.cs
@@ -910,7 +910,9 @@ public bool ReloadMedia(bool unconditionally = true)
}
InvalidateMediaParams(keepBlockSize: false);
- EnsureOnContentPartition(); // CHECKME: do we indeed need to force to Content partition right away? or is it ok to fill the content param cache somehwta later?
+ EnsureOnContentPartition(); // CHECKME: do we indeed need to force to Content partition right away? or is it ok to fill the content param cache later?
+
+ ReloadDriveCaps(); // refresh drive caps after media load, as some drives (e.g. virtual ones) change their capabilities depending on the media loaded
ResetEarlyWarningRuntime();
diff --git a/TapeLibNET/Virtual/VirtualTapeDriveBackend.cs b/TapeLibNET/Virtual/VirtualTapeDriveBackend.cs
index f9d8ff1..d27f4c9 100644
--- a/TapeLibNET/Virtual/VirtualTapeDriveBackend.cs
+++ b/TapeLibNET/Virtual/VirtualTapeDriveBackend.cs
@@ -97,7 +97,10 @@ public partial class VirtualTapeDriveBackend : TapeDriveBackend
#region *** Private Fields ***
- private readonly VirtualTapeDriveCapabilities m_capabilities;
+ // Not readonly: block-size capabilities are reconciled against the actual loaded
+ // media state in LoadMedia(), since existing media may have been created with
+ // different block-size limits than the capabilities passed at construction time.
+ private VirtualTapeDriveCapabilities m_capabilities;
private VirtualTapeMedia? m_contentMedia;
private VirtualTapeMedia? m_initiatorMedia;
private VirtualTapeMedia? m_currentMedia;
@@ -336,17 +339,17 @@ public override string DeviceName
public override string Revision => "v1";
-///
-/// Controls how LoadMedia() handles existing vs new media state:
-///
-/// — Require existing state; fail if not found.
-/// — Always create new media; truncate any existing state.
-/// — Create new media; fail if valid state already exists.
-/// — Load existing state if available; otherwise create new.
-///
-/// Default is .
-///
-public FileMode MediaMode { get; set; } = FileMode.OpenOrCreate;
+ ///
+ /// Controls how LoadMedia() handles existing vs new media state:
+ ///
+ /// — Require existing state; fail if not found.
+ /// — Always create new media; truncate any existing state.
+ /// — Create new media; fail if valid state already exists.
+ /// — Load existing state if available; otherwise create new.
+ ///
+ /// Default is .
+ ///
+ public FileMode MediaMode { get; set; } = FileMode.OpenOrCreate;
#endregion
@@ -472,6 +475,11 @@ public override bool LoadMedia()
}
m_logger.LogTrace("{Prefix}: Loaded content media from existing state", LogPrefix);
+
+ // Reconcile capabilities' block-size limits with the actually loaded media,
+ // which may have been created with different limits than currently configured
+ // (e.g. a smaller MinBlockSize than the default capabilities provide).
+ ReconcileBlockSizeCapabilities(m_contentMedia);
}
else
{
@@ -588,6 +596,32 @@ public override bool LoadMedia()
return true;
}
+ ///
+ /// Reconciles the drive's block-size capabilities with the block-size limits actually
+ /// enforced by loaded media state. Existing media may have been created with different
+ /// MinBlockSize/MaxBlockSize/DefaultBlockSize than the capabilities passed to this
+ /// backend at construction time, so the reported capabilities must reflect what the
+ /// loaded media will actually accept.
+ ///
+ private void ReconcileBlockSizeCapabilities(VirtualTapeMedia media)
+ {
+ if (media.MinBlockSize == m_capabilities.MinBlockSize
+ && media.MaxBlockSize == m_capabilities.MaxBlockSize
+ && media.DefaultBlockSize == m_capabilities.DefaultBlockSize)
+ return;
+
+ m_logger.LogTrace(
+ "{Prefix}: Reconciling block-size capabilities from loaded media - min: {Min}, max: {Max}, default: {Default}",
+ LogPrefix, media.MinBlockSize, media.MaxBlockSize, media.DefaultBlockSize);
+
+ m_capabilities = m_capabilities with
+ {
+ MinBlockSize = media.MinBlockSize,
+ MaxBlockSize = media.MaxBlockSize,
+ DefaultBlockSize = media.DefaultBlockSize,
+ };
+ }
+
///
/// Truncates a stream to zero length (best effort).
/// Used when creating new media to discard any stale data from previous state.
diff --git a/TapeLibNET/Virtual/VirtualTapeMedia.cs b/TapeLibNET/Virtual/VirtualTapeMedia.cs
index db16f0c..8b3edd4 100644
--- a/TapeLibNET/Virtual/VirtualTapeMedia.cs
+++ b/TapeLibNET/Virtual/VirtualTapeMedia.cs
@@ -323,6 +323,7 @@ public uint BlockSize
}
public uint MinBlockSize => m_minBlockSize;
public uint MaxBlockSize => m_maxBlockSize;
+ public uint DefaultBlockSize => m_defaultBlockSize;
public long Capacity => m_capacity;
public long Remaining => ReportedRemaining();
public long CurrentBlock => m_currentBlock;
From d831610fa93daf15063216b401042267c1777d46 Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Wed, 12 Aug 2026 13:53:16 +0200
Subject: [PATCH 16/37] For tape drive calibration, impolement automatically
finer step for the tail section (by default, 20% of calibration samples for
the last 5% of media capacity or from EW on, whichever comes first).
Implement SCSI log sense based remaning capacity inquiry to second-guess what
tape driver reprts esp. near EW / EOM.
---
TapeLibNET/TapeCalibration.cs | 48 +++-
TapeLibNET/TapeCalibrationOptions.cs | 145 +++++++++--
TapeLibNET/TapeCalibrator.cs | 175 +++++++++++---
TapeLibNET/TapeDriveWin32Backend.Lto.cs | 118 +++++++++
.../Controls/CalibrationCurveControl.xaml | 26 +-
.../Controls/CalibrationCurveControl.xaml.cs | 226 +++++++++++++++---
6 files changed, 630 insertions(+), 108 deletions(-)
diff --git a/TapeLibNET/TapeCalibration.cs b/TapeLibNET/TapeCalibration.cs
index 14cbb14..e99708e 100644
--- a/TapeLibNET/TapeCalibration.cs
+++ b/TapeLibNET/TapeCalibration.cs
@@ -117,13 +117,23 @@ public sealed class TapeCalibration : ITapeCalibration
public IReadOnlyList Curve { get; }
public CalibrationPoint? EarlyWarning { get; }
+ ///
+ /// EXPERIMENTAL parallel series: the drive's OWN remaining figure (SCSI LOG SENSE, Tape Capacity
+ /// page 0x31) transformed to the same reported → actual shape as . Null on
+ /// older blobs and on non-LTO runs. Kept ALONGSIDE (never replacing) so the
+ /// runtime is unaffected while we compare the two offline — in particular to see whether the native
+ /// figure dodges the driver's tail quirks (e.g. the LTO-3 collapse, LTO-4 phantom).
+ ///
+ public IReadOnlyList? LtoRemainingCurve { get; }
+
#endregion
#region *** Construction ***
private TapeCalibration(
string formatId, string profileKey, long reportedCapacityAtBom, long phantomFreeAtEom,
- long capacityActual, IReadOnlyList curve, CalibrationPoint? earlyWarning)
+ long capacityActual, IReadOnlyList curve, CalibrationPoint? earlyWarning,
+ IReadOnlyList? ltoRemainingCurve = null)
{
FormatId = formatId;
ProfileKey = profileKey;
@@ -132,6 +142,7 @@ private TapeCalibration(
CapacityActual = capacityActual;
Curve = curve;
EarlyWarning = earlyWarning;
+ LtoRemainingCurve = ltoRemainingCurve;
}
///
@@ -147,7 +158,8 @@ private TapeCalibration(
public static TapeCalibration FromMeasurements(
string profileKey, long reportedCapacityAtBom, long capacityActual,
IEnumerable<(long ActualWritten, long ReportedRemaining)> rawSamples,
- (long ActualWritten, long ReportedRemaining)? earlyWarning)
+ (long ActualWritten, long ReportedRemaining)? earlyWarning,
+ IEnumerable<(long ActualWritten, long LtoRemaining)>? ltoSamples = null)
{
var pts = new List();
@@ -183,8 +195,29 @@ public static TapeCalibration FromMeasurements(
? new CalibrationPoint(ew.ReportedRemaining, Math.Max(0L, capacityActual - ew.ActualWritten))
: null;
+ // Build the optional LTO (LOG SENSE) parallel series with the same reported→actual transform and
+ // conservative-tie dedup as the main curve, so the two are directly comparable point-for-point.
+ List? ltoCurve = null;
+ if (ltoSamples is not null)
+ {
+ var lp = new List();
+ foreach (var (aw, lto) in ltoSamples)
+ if (lto >= 0)
+ lp.Add(new CalibrationPoint(lto, Math.Max(0L, capacityActual - aw)));
+
+ lp.Sort(static (a, b) =>
+ a.ReportedRemaining != b.ReportedRemaining
+ ? a.ReportedRemaining.CompareTo(b.ReportedRemaining)
+ : a.ActualRemaining.CompareTo(b.ActualRemaining));
+
+ ltoCurve = new List(lp.Count);
+ foreach (var p in lp)
+ if (ltoCurve.Count == 0 || ltoCurve[^1].ReportedRemaining != p.ReportedRemaining)
+ ltoCurve.Add(p);
+ }
+
return new TapeCalibration(CurrentFormatId, profileKey, Math.Max(0L, reportedCapacityAtBom),
- phantomFreeAtEom, capacityActual, curve, ewPoint);
+ phantomFreeAtEom, capacityActual, curve, ewPoint, ltoCurve);
}
///
@@ -366,7 +399,9 @@ private sealed record Dto(
long PhantomFreeAtEom,
long CapacityActual,
List Curve,
- CalibrationPoint? EarlyWarning);
+ CalibrationPoint? EarlyWarning,
+ List? LtoRemainingCurve = null // appended → older blobs read back as null
+ );
private static readonly JsonSerializerOptions s_json = new()
{
@@ -377,7 +412,7 @@ public void SaveTo(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
var dto = new Dto(FormatId, ProfileKey, ReportedCapacityAtBom, PhantomFreeAtEom, CapacityActual,
- [.. Curve], EarlyWarning);
+ [.. Curve], EarlyWarning, LtoRemainingCurve is null ? null : [.. LtoRemainingCurve]);
JsonSerializer.Serialize(stream, dto, s_json);
}
@@ -401,7 +436,8 @@ public void SaveTo(Stream stream)
var curve = dto.Curve ?? [];
return new TapeCalibration(dto.FormatId, dto.ProfileKey,
- dto.ReportedCapacityAtBom, dto.PhantomFreeAtEom, dto.CapacityActual, curve, dto.EarlyWarning);
+ dto.ReportedCapacityAtBom, dto.PhantomFreeAtEom, dto.CapacityActual, curve, dto.EarlyWarning,
+ dto.LtoRemainingCurve);
}
catch (JsonException)
{
diff --git a/TapeLibNET/TapeCalibrationOptions.cs b/TapeLibNET/TapeCalibrationOptions.cs
index f7cfb0f..45306dd 100644
--- a/TapeLibNET/TapeCalibrationOptions.cs
+++ b/TapeLibNET/TapeCalibrationOptions.cs
@@ -1,70 +1,167 @@
-namespace TapeLibNET;
+using System;
+
+namespace TapeLibNET;
///
/// Caller intent for a calibration run. The calibrator resolves this against a specific
/// into a concrete . Defaults target a
/// correct, deterministic measurement: the drive's maximum block size, hardware compression off,
-/// and ~ curve points spread across the medium.
+/// and ~ curve points spread across the medium, with a reserved fraction
+/// of that budget spent on a FINE-GRAINED tail (the EW → EOM region, where accuracy matters most).
///
public readonly record struct TapeCalibrationOptions
{
- /// Approximate number of ReportedRemaining → ActualRemaining curve points to record. Default 100.
+ /// Approximate number of ReportedRemaining → ActualRemaining curve points to record.
+ /// Default 1,000 proved good resolution for LTO drives — but its EW→EOM tail needs more (see below).
public int SampleCount { get; init; }
- /// Payload size per WriteDirect call, in blocks. ≤ 0 falls back to .
+ /// Payload size per WriteDirect call, in blocks (BODY phase). ≤ 0 falls back to .
public int BlocksPerChunk { get; init; }
+ ///
+ /// Fraction of reserved for the fine-grained TAIL phase (the EW → EOM
+ /// region). Real LTO runs showed the default 100/1,000 uniform points far too coarse for that
+ /// last stretch — LTO-3 in particular collapses its reported figure right at EW — so we spend a
+ /// dedicated slice of the budget there, at a proportionally finer chunk. Default 0.20 (20%).
+ ///
+ public double TailSampleFraction { get; init; }
+
+ ///
+ /// The tail begins at whichever comes FIRST while writing toward EOM: the drive's physical early
+ /// warning, OR the last of capacity. The capacity trigger
+ /// guarantees a fine tail even when EW fires extremely late (LTO-3: ~0.1% before EOM). Default 0.05 (5%).
+ ///
+ public double TailCapacityFraction { get; init; }
+
/// Default value for .
public const int DefaultBlocksPerChunk = 8;
+ /// Default value for — 20% of the sample budget goes to the tail.
+ public const double DefaultTailSampleFraction = 0.20;
+
+ /// Default value for — the tail is the last 5% of capacity (or EW, whichever first).
+ public const double DefaultTailCapacityFraction = 0.05;
+
public TapeCalibrationOptions()
{
- SampleCount = 100;
+ SampleCount = 1_000; // 1,000 proved good resolution for LTO drives
BlocksPerChunk = DefaultBlocksPerChunk;
+ TailSampleFraction = DefaultTailSampleFraction; // reserve 20% of the budget for the EW→EOM tail
+ TailCapacityFraction = DefaultTailCapacityFraction; // tail = last 5% of capacity (or EW, whichever first)
}
/// Turn caller intent into a concrete, always-valid plan for this drive.
public TapeCalibrationPlan ResolveFor(TapeDrive drive)
{
ArgumentNullException.ThrowIfNull(drive);
+
+ long capacity = Math.Max(1L, drive.ContentCapacity);
+
+ int sampleCount = Math.Max(1, SampleCount);
+ double tailSampleFraction = Math.Clamp(TailSampleFraction, 0.0, 0.9);
+ double tailCapacityFraction = Math.Clamp(TailCapacityFraction, 0.0, 0.9);
+
+ // Split the sample budget: a reserved slice for the fine tail, the rest for the body.
+ int tailSampleCount = Math.Max(1, (int)(sampleCount * tailSampleFraction));
+ int bodySampleCount = Math.Max(1, sampleCount - tailSampleCount);
+
int blocksPerChunk = BlocksPerChunk > 0 ? BlocksPerChunk : DefaultBlocksPerChunk;
uint blockSize = drive.MaximumBlockSize;
- var plan = TapeCalibrationPlan.Create(SampleCount, blockSize, blocksPerChunk);
- // Check if the ChunkSize isn't too coarse to reach SampleCount
- long sampleStep = drive.ContentCapacity / plan.SampleCount;
- if (plan.ChunkSize <= sampleStep)
- return plan; // if yes, we're good to go
+ // --- BODY chunk sizing: keep the chunk fine enough to reach bodySampleCount across the body zone
+ // (the first (1 - tailCapacityFraction) of capacity). ---
+ long bodyZone = Math.Max(1L, (long)(capacity * (1.0 - tailCapacityFraction)));
+ long bodyStep = Math.Max(1L, bodyZone / bodySampleCount);
- // If not, first try to reduce BlocksPerChunk
- blocksPerChunk = (int)(sampleStep / blockSize);
- if (blocksPerChunk <= 0)
+ if ((long)blocksPerChunk * blockSize > bodyStep)
{
- // if still too coarse, reduce BlockSize to the drive's default
- blockSize = drive.DefaultBlockSize;
- blocksPerChunk = Math.Max(1, (int)(sampleStep / blockSize)); // we won't reduce any further
+ // Too coarse to reach bodySampleCount: first try to reduce BlocksPerChunk
+ blocksPerChunk = (int)(bodyStep / blockSize);
+
+ if (blocksPerChunk <= 0)
+ {
+ // if still too coarse, reduce BlockSize to the drive's default (we won't reduce any further)
+ blockSize = drive.DefaultBlockSize > 0 ? drive.DefaultBlockSize : blockSize;
+ blocksPerChunk = Math.Max(1, (int)(bodyStep / blockSize));
+ }
}
+ blocksPerChunk = Math.Max(1, blocksPerChunk);
- plan = TapeCalibrationPlan.Create(SampleCount, blockSize, blocksPerChunk);
+ // --- TAIL chunk sizing: finer, so tailSampleCount samples span the last tailCapacityFraction.
+ // For small (virtual) media the tail step floors to a single block, as for body. ---
+ long tailZone = Math.Max(1L, (long)(capacity * tailCapacityFraction));
+ long tailStep = Math.Max(1L, tailZone / tailSampleCount);
- return plan;
+ int tailBlocksPerChunk = Math.Max(1, (int)(tailStep / blockSize));
+ // The tail must be at least as fine as the body — never coarser.
+ tailBlocksPerChunk = Math.Min(tailBlocksPerChunk, blocksPerChunk);
+
+ return TapeCalibrationPlan.Create(
+ sampleCount, bodySampleCount, tailSampleCount,
+ blockSize, blocksPerChunk, tailBlocksPerChunk, tailCapacityFraction);
}
}
///
/// Fully resolved run parameters — everything the calibrator needs to configure the drive, nothing
-/// more. Every field is concrete; is always valid (no divide-by-zero path).
+/// more. Every field is concrete; and are always
+/// valid (no divide-by-zero path). The run has two phases: a coarse BODY (chunk ,
+/// interval ) and a fine TAIL (chunk ,
+/// interval ) that begins at EW or the last .
///
public readonly record struct TapeCalibrationPlan(
int SampleCount,
+ int BodySampleCount,
+ int TailSampleCount,
uint BlockSize,
int BlocksPerChunk,
- int ChunkSize)
+ int ChunkSize,
+ int TailBlocksPerChunk,
+ int TailChunkSize,
+ double TailCapacityFraction)
{
- /// Build a plan, deriving and clamping to ≥ 1.
- internal static TapeCalibrationPlan Create(int sampleCount, uint blockSize, int blocksPerChunk)
+ /// Build a plan, deriving the two chunk sizes and clamping the counts to ≥ 1.
+ internal static TapeCalibrationPlan Create(
+ int sampleCount, int bodySampleCount, int tailSampleCount,
+ uint blockSize, int blocksPerChunk, int tailBlocksPerChunk, double tailCapacityFraction)
{
int chunkSize = checked((int)(blocksPerChunk * (long)blockSize));
- return new TapeCalibrationPlan(Math.Max(1, sampleCount), blockSize, blocksPerChunk, chunkSize);
+ int tailChunkSize = checked((int)(tailBlocksPerChunk * (long)blockSize));
+
+ return new TapeCalibrationPlan(
+ Math.Max(1, sampleCount),
+ Math.Max(1, bodySampleCount),
+ Math.Max(1, tailSampleCount),
+ blockSize,
+ Math.Max(1, blocksPerChunk), chunkSize,
+ Math.Max(1, tailBlocksPerChunk), tailChunkSize,
+ tailCapacityFraction);
+ }
+
+ ///
+ /// Re-derives the two chunk sizes for a block size the drive actually accepted (it may round the
+ /// requested max to its own granularity), keeping the blocks-per-chunk and sample counts intact.
+ ///
+ internal TapeCalibrationPlan WithBlockSize(uint newBlockSize)
+ {
+ int chunkSize = checked((int)(BlocksPerChunk * (long)newBlockSize));
+ int tailChunkSize = checked((int)(TailBlocksPerChunk * (long)newBlockSize));
+
+ return this with { BlockSize = newBlockSize, ChunkSize = chunkSize, TailChunkSize = tailChunkSize };
}
-}
\ No newline at end of file
+
+ /// Bytes-written mark at which the tail phase begins (the last
+ /// of ). The calibrator ALSO enters the tail early if physical EW fires first.
+ public long TailStartBytes(long capacity)
+ => (long)(capacity * (1.0 - TailCapacityFraction));
+
+ /// Sample cadence for the body phase: never finer than one body chunk, ~
+ /// points across the body zone.
+ public long BodySampleInterval(long capacity)
+ => Math.Max(ChunkSize, (long)(capacity * (1.0 - TailCapacityFraction)) / Math.Max(1, BodySampleCount));
+
+ /// Sample cadence for the tail phase: never finer than one tail chunk, ~
+ /// points across the last of the medium.
+ public long TailSampleInterval(long capacity)
+ => Math.Max(TailChunkSize, (long)(capacity * TailCapacityFraction) / Math.Max(1, TailSampleCount));
+}
diff --git a/TapeLibNET/TapeCalibrator.cs b/TapeLibNET/TapeCalibrator.cs
index 00bf95e..7a4e42e 100644
--- a/TapeLibNET/TapeCalibrator.cs
+++ b/TapeLibNET/TapeCalibrator.cs
@@ -15,7 +15,16 @@ public readonly record struct TapeCalibrationProgress(
long PositionBlock,
bool EarlyWarning,
bool EndOfMedium,
- string Phase);
+ string Phase)
+{
+ ///
+ /// EXPERIMENTAL cross-check: the drive's OWN remaining-capacity figure read directly over SCSI
+ /// (LOG SENSE, Tape Capacity page 0x31), bypassing the Windows tape class driver. -1 when not
+ /// available (non-LTO drive, or the probe failed). Declared as a non-positional init property so
+ /// existing positional new TapeCalibrationProgress(...) calls keep compiling unchanged.
+ ///
+ public long LtoReportedRemaining { get; init; } = -1L;
+}
///
/// One-shot, destructive early-warning / capacity calibrator. Rewinds the loaded scratch medium,
@@ -24,14 +33,32 @@ public readonly record struct TapeCalibrationProgress(
/// the application can persist and later hand to
/// .
///
+/// Sampling is TWO-PHASE (see ): a coarse BODY across most of the
+/// medium, then a fine TAIL over the EW → EOM region (entered at physical EW or the last few percent
+/// of capacity, whichever comes first). Real LTO runs proved a uniform cadence far too coarse in that
+/// tail — LTO-4 keeps ~31 GB of phantom-free runway past EW, while LTO-3 collapses its reported
+/// figure to 0 the instant EW fires — so the tail earns a dedicated, proportionally finer chunk.
+///
+///
/// Conceptually create-use-discard: new TapeCalibrator(drive).Run(). Backend-agnostic — it
/// drives only the public surface, so it works identically for the Win32,
-/// remote, and virtual backends. Cancellation is cooperative via
-/// (poll/flip from the caller's async wrapper), mirroring TapeFileAgent.
+/// remote, and virtual backends. The one EXPERIMENTAL exception is the optional native (LTO) remaining
+/// probe, which reaches into a Win32 backend directly to cross-check the driver figure. Cancellation is
+/// cooperative via (poll/flip from the caller's async wrapper),
+/// mirroring TapeFileAgent.
///
///
public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder(drive)
{
+ #region *** Constants ***
+
+ // Above this |LTO − driver| gap we log the divergence at Information (else Trace). The reported
+ // COLLAPSE (driver 0 while the drive's own LOG SENSE still claims capacity) is ALWAYS logged at
+ // Information regardless of this threshold, since it is the exact quirk the tail phase exists to tame.
+ private const long c_ltoDivergenceTraceThreshold = 1L * 1024 * 1024 * 1024; // 1 GB
+
+ #endregion
+
#region *** Options & Cancellation ***
/// Run options; defaults are sensible for LTO and most linear-tape drives.
@@ -89,6 +116,15 @@ private bool CheckForAbort()
// --- Resolve caller intent into a concrete, drive-specific plan ---
TapeCalibrationPlan plan = Options.ResolveFor(Drive);
+ // --- First of all, position at BOM of the content partition ---
+ // to ensure the new block size applies to the content partition!
+ if (!Drive.MoveToPartition(MediaPartition.Content) || !Drive.Rewind())
+ {
+ SyncErrorFrom(Drive);
+ LogErrorAsDebug("Calibration: failed to rewind content partition");
+ return null;
+ }
+
// --- Configure the drive for a deterministic byte→position mapping ---
if (!Drive.SetBlockSize(plan.BlockSize))
{
@@ -104,37 +140,28 @@ private bool CheckForAbort()
SetError(WIN32_ERROR.ERROR_INVALID_PARAMETER);
else
SyncErrorFrom(Drive);
+
LogErrorAsDebug("Calibration: drive reports zero block size");
return null;
}
- // The drive may round the requested max to its own granularity; re-derive the plan so
- // ChunkSize stays consistent with what the hardware actually accepted.
+ // The drive may round the requested max to its own granularity; re-derive the chunks so
+ // ChunkSize/TailChunkSize stay consistent with what the hardware actually accepted.
if (blockSize != plan.BlockSize)
{
- m_logger.LogWarning("{Prefix}: Calibration — drive adjusted block size {Requested} → {Effective}; re-deriving chunk",
+ m_logger.LogWarning("{Prefix}: Calibration — drive adjusted block size {Requested} → {Effective}; re-deriving chunks",
LogPrefix, plan.BlockSize, blockSize);
- plan = TapeCalibrationPlan.Create(plan.SampleCount, blockSize, plan.BlocksPerChunk);
- }
- int chunkSize = plan.ChunkSize;
+ plan = plan.WithBlockSize(blockSize);
+ }
// Now move to BOM and determine the capacity reported at BOM
long capacityReportedAtBom;
-
try
{
// Hardware compression OFF so incompressible bytes map 1:1 to tape position.
Drive.SetHardwareCompression(false);
- // --- Position at BOM of the content partition ---
- if (!Drive.MoveToPartition(MediaPartition.Content) || !Drive.Rewind())
- {
- SyncErrorFrom(Drive);
- LogErrorAsDebug("Calibration: failed to rewind content partition");
- return null;
- }
-
// To get correct reported remaining at BOM, we must first write a small block to the tape
// -- otherwise, in case the media isn't empty, the drive will report partial remaining!
if (!Drive.WriteGapFile())
@@ -162,6 +189,7 @@ private bool CheckForAbort()
SetError(WIN32_ERROR.ERROR_INVALID_PARAMETER);
else
SyncErrorFrom(Drive);
+
LogErrorAsDebug("Calibration: drive reports zero capacity at BOM");
return null;
}
@@ -172,29 +200,76 @@ private bool CheckForAbort()
SetError(WIN32_ERROR.ERROR_IO_DEVICE);
else
SyncErrorFrom(Drive);
+
m_logger.LogError(ex, "{Prefix}: Calibration: exception during setup", LogPrefix);
throw; // we don't catch exceptions here -- the caller is reposible for handling them
}
- // --- Prepare an incompressible payload chunk (whole blocks) ---
+ // --- Prepare an incompressible payload chunk (whole blocks, BODY size — the largest we write) ---
using TapeWriteBufferPool pool = new();
- var buffer = pool.Rent(chunkSize);
+ var buffer = pool.Rent(plan.ChunkSize);
Random.Shared.NextBytes(buffer.Data()); // random ⇒ incompressible; reused every write (compression is off)
- // --- Sample cadence: never finer than one chunk, ~SampleCount points across the medium ---
- long sampleInterval = Math.Max(plan.ChunkSize, capacityReportedAtBom / plan.SampleCount);
+ // --- Two-phase sample cadence: coarse body, fine tail; the tail starts at EW or the last few percent ---
+ long bodySampleInterval = plan.BodySampleInterval(capacityReportedAtBom);
+ long tailSampleInterval = plan.TailSampleInterval(capacityReportedAtBom);
+ long tailStartBytes = plan.TailStartBytes(capacityReportedAtBom);
+
+ // EXPERIMENTAL: probe the drive's own remaining figure over SCSI (LOG SENSE 0x31), alongside the
+ // driver-reported one, so we can decide offline whether it dodges the tail quirks (esp. the LTO-3
+ // collapse). Only meaningful on a Win32 LTO backend; a no-op (−1) otherwise.
+ TapeDriveWin32Backend? ltoBackend = Drive.Backend as TapeDriveWin32Backend;
+ bool probeLto = ltoBackend?.IsLto == true;
m_logger.LogInformation(
- "{Prefix}: Calibration start — profile '{Key}', reportedCapacityAtBom {Cap}, blockSize {Bs}, chunk {Chunk}, sampleInterval {Int}",
- LogPrefix, Drive.DriveProfileKey, capacityReportedAtBom, blockSize, chunkSize, sampleInterval);
+ "{Prefix}: Calibration start — profile '{Key}', reportedCapacityAtBom {Cap}, blockSize {Bs}, " +
+ "bodyChunk {BChunk}, tailChunk {TChunk}, bodyInterval {BInt}, tailInterval {TInt}, tailStart {TStart}, " +
+ "samples {Samples} (body {Body} + tail {Tail}), ltoProbe {Lto}",
+ LogPrefix, Drive.DriveProfileKey, capacityReportedAtBom, blockSize,
+ plan.ChunkSize, plan.TailChunkSize, bodySampleInterval, tailSampleInterval, tailStartBytes,
+ plan.SampleCount, plan.BodySampleCount, plan.TailSampleCount, probeLto);
// --- Write to hard EOM, sampling as we go ---
var samples = new List<(long ActualWritten, long ReportedRemaining)>();
+ var ltoSamples = new List<(long ActualWritten, long LtoRemaining)>();
(long ActualWritten, long ReportedRemaining)? ewPoint = null;
-
long bytesWritten = 0;
long nextSample = 0;
+ bool inTail = false;
+ int currentChunk = plan.ChunkSize; // body chunk; shrinks to plan.TailChunkSize in the tail
+ long sampleInterval = bodySampleInterval; // body cadence; tightens to tailSampleInterval in the tail
+
+ // Local: read the drive's native (LOG SENSE) remaining, record it against the driver figure, and
+ // trace any divergence — spotlighting the COLLAPSE (driver 0 while the drive still claims space).
+ long SampleLtoRemaining(long reportedRemaining, long actualWritten)
+ {
+ if (!probeLto || ltoBackend is null)
+ return -1L;
+
+ if (!ltoBackend.GetLtoRemainingCapacity(out long ltoRem, out _))
+ return -1L;
+
+ ltoSamples.Add((actualWritten, ltoRem));
+
+ long divergence = ltoRem - reportedRemaining;
+ if (reportedRemaining <= 0 && ltoRem > 0)
+ m_logger.LogInformation(
+ "{Prefix}: Reported COLLAPSE — driver 0, LOG SENSE {Lto} still remaining (actualWritten {Aw})",
+ LogPrefix, ltoRem, actualWritten);
+ else if (Math.Abs(divergence) > c_ltoDivergenceTraceThreshold)
+ m_logger.LogInformation(
+ "{Prefix}: Reported/LOG SENSE divergence {Div} (driver {Rep}, LOG SENSE {Lto}, actualWritten {Aw})",
+ LogPrefix, divergence, reportedRemaining, ltoRem, actualWritten);
+ else
+ m_logger.LogTrace(
+ "{Prefix}: LOG SENSE remaining {Lto} vs driver {Rep} (actualWritten {Aw})",
+ LogPrefix, ltoRem, reportedRemaining, actualWritten);
+
+ return ltoRem;
+ }
+
+ long ltoAtBom = SampleLtoRemaining(capacityReportedAtBom, 0L);
samples.Add((ActualWritten: 0L, ReportedRemaining: capacityReportedAtBom));
try
@@ -204,7 +279,7 @@ private bool CheckForAbort()
if (CheckForAbort())
return null;
- int written = Drive.WriteDirect(buffer.Array, buffer.Offset, chunkSize,
+ int written = Drive.WriteDirect(buffer.Array, buffer.Offset, currentChunk,
out _ /* tapemark */, out _ /* ew (gated on reserve, unused here) */, out bool eom);
bytesWritten += written;
@@ -215,18 +290,43 @@ private bool CheckForAbort()
{
long rrEw = Drive.GetReportedContentRemaining();
ewPoint = (bytesWritten, rrEw);
+ long ltoEw = SampleLtoRemaining(rrEw, bytesWritten);
+ samples.Add((bytesWritten, rrEw));
+
progress?.Report(new TapeCalibrationProgress(
- bytesWritten, rrEw, Drive.GetCurrentBlock(), EarlyWarning: true, EndOfMedium: false, "early-warning"));
+ bytesWritten, rrEw, Drive.GetCurrentBlock(), EarlyWarning: true, EndOfMedium: false, "early-warning")
+ { LtoReportedRemaining = ltoEw });
+
m_logger.LogInformation("{Prefix}: Calibration EW at {Bytes} bytes (reportedRemaining {RR})",
LogPrefix, bytesWritten, rrEw);
}
+ // Enter the fine-grained TAIL phase at whichever comes first: the drive's physical EW, or the
+ // last TailCapacityFraction of capacity. From here the write chunk shrinks and the cadence
+ // tightens, so the EW→EOM stretch — where LTO reporting misbehaves — is densely sampled.
+ if (!inTail && (Drive.IsPhysicalEarlyWarningSeen || bytesWritten >= tailStartBytes))
+ {
+ inTail = true;
+ currentChunk = plan.TailChunkSize;
+ sampleInterval = tailSampleInterval;
+ nextSample = bytesWritten; // sample immediately at tail entry
+
+ m_logger.LogInformation(
+ "{Prefix}: Calibration entering TAIL at {Bytes} bytes (chunk {Chunk}, interval {Int}) — {Reason}",
+ LogPrefix, bytesWritten, currentChunk, sampleInterval,
+ Drive.IsPhysicalEarlyWarningSeen ? "physical early warning" : "last capacity fraction");
+ }
+
if (eom)
{
long rrEom = Drive.GetReportedContentRemaining();
+ long ltoEom = SampleLtoRemaining(rrEom, bytesWritten);
samples.Add((bytesWritten, rrEom));
+
progress?.Report(new TapeCalibrationProgress(
- bytesWritten, rrEom, Drive.GetCurrentBlock(), EarlyWarning: ewPoint is not null, EndOfMedium: true, "eom"));
+ bytesWritten, rrEom, Drive.GetCurrentBlock(), EarlyWarning: ewPoint is not null, EndOfMedium: true, "eom")
+ { LtoReportedRemaining = ltoEom });
+
m_logger.LogInformation("{Prefix}: Calibration EOM at {Bytes} bytes (reportedRemaining {RR}) — actual capacity",
LogPrefix, bytesWritten, rrEom);
break;
@@ -241,6 +341,7 @@ private bool CheckForAbort()
LogErrorAsDebug("Calibration: write failed before EOM");
return null;
}
+
// Defensive: avoid a busy spin if the drive returns 0 without error.
SetError(WIN32_ERROR.ERROR_IO_DEVICE);
LogErrorAsWarning("Calibration: write returned 0 bytes without EOM — stopping");
@@ -250,9 +351,14 @@ private bool CheckForAbort()
if (bytesWritten >= nextSample)
{
long rr = Drive.GetReportedContentRemaining();
+ long lto = SampleLtoRemaining(rr, bytesWritten);
samples.Add((bytesWritten, rr));
+
progress?.Report(new TapeCalibrationProgress(
- bytesWritten, rr, Drive.GetCurrentBlock(), EarlyWarning: ewPoint is not null, EndOfMedium: false, "sampling"));
+ bytesWritten, rr, Drive.GetCurrentBlock(), EarlyWarning: ewPoint is not null, EndOfMedium: false,
+ inTail ? "sampling-tail" : "sampling")
+ { LtoReportedRemaining = lto });
+
nextSample += sampleInterval;
}
}
@@ -266,16 +372,17 @@ private bool CheckForAbort()
}
TapeCalibration calibration = TapeCalibration.FromMeasurements(
- Drive.DriveProfileKey, capacityReportedAtBom, capacityActual, samples, ewPoint);
+ Drive.DriveProfileKey, capacityReportedAtBom, capacityActual, samples, ewPoint,
+ ltoSamples.Count > 0 ? ltoSamples : null);
m_logger.LogInformation(
"{Prefix}: Calibration done — actualCapacity {Act} ({Pct:F1}% of reported at BOM), " +
- "phantomFreeAtEom {Phantom}, EW {Ew}, points {N}",
+ "phantomFreeAtEom {Phantom}, EW {Ew}, points {N} (LOG SENSE points {Lto})",
LogPrefix, capacityActual,
calibration.ReportedCapacityAtBom > 0 ? 100.0 * capacityActual / calibration.ReportedCapacityAtBom : 0.0,
calibration.PhantomFreeAtEom,
ewPoint is { } e ? $"{e.ActualWritten} bytes / RR {e.ReportedRemaining}" : "(none)",
- samples.Count);
+ samples.Count, ltoSamples.Count);
ResetError();
return calibration;
@@ -286,6 +393,7 @@ private bool CheckForAbort()
SetError(WIN32_ERROR.ERROR_IO_DEVICE);
else
SyncErrorFrom(Drive);
+
m_logger.LogError(ex, "{Prefix}: Calibration: exception during setup", LogPrefix);
throw; // we don't catch exceptions here -- the caller is reposible for handling them
}
@@ -294,6 +402,7 @@ private bool CheckForAbort()
// Restore the caller's reserve and calibrations regardless of how the run ended.
foreach (var c in savedCalibrations)
Drive.AddCalibration(c);
+
Drive.SetEarlyWarning(savedReserve);
Drive.ResetEarlyWarningRuntime();
@@ -302,4 +411,4 @@ private bool CheckForAbort()
}
#endregion
-}
\ No newline at end of file
+}
diff --git a/TapeLibNET/TapeDriveWin32Backend.Lto.cs b/TapeLibNET/TapeDriveWin32Backend.Lto.cs
index ad02992..f9107c5 100644
--- a/TapeLibNET/TapeDriveWin32Backend.Lto.cs
+++ b/TapeLibNET/TapeDriveWin32Backend.Lto.cs
@@ -1092,4 +1092,122 @@ internal bool IsBeyondProgrammableEarlyWarning()
#endregion
+ #region *** Tape Capacity — LOG SENSE (page 0x31) ***
+
+ // =============================================================================
+ // The drive's OWN remaining/maximum capacity, read straight from the device via
+ // LOG SENSE(10) + Tape Capacity log page (0x31), bypassing the tape class driver's
+ // (tape.sys) GetTapeParameters().Remaining. Real runs showed the driver figure both
+ // UNDER-reporting capacity at BOM and — on LTO-3 — COLLAPSING to 0 the instant EW
+ // fires. This native figure lets us cross-check (and potentially replace) it.
+ //
+ // >>> VERIFY THE UNITS (c_tapeCapBytesPerUnit) against your drive's SCSI reference.
+ // >>> SSC nominally expresses these parameters in megabytes; some drives scale
+ // >>> differently. GetLtoRemainingCapacity logs the raw parameter values at Trace.
+ // =============================================================================
+
+ private const byte c_scsiOpLogSense10 = 0x4D;
+ private const byte c_logPageTapeCapacity = 0x31;
+
+ // Tape Capacity page parameter codes (SSC): main partition remaining / maximum.
+ private const ushort c_tapeCapParamMainRemaining = 0x0001;
+ private const ushort c_tapeCapParamMainMaximum = 0x0003;
+
+ // Page values are in megabytes per SSC — VERIFY per drive (see note above).
+ private const long c_tapeCapBytesPerUnit = 1024L * 1024;
+
+ // Generous allocation for the page header + the four capacity parameters.
+ private const int c_logSenseAllocLen = 128;
+
+ ///
+ /// Reads the drive's own remaining- and maximum-capacity figures for the MAIN (content) partition
+ /// via SCSI LOG SENSE(10) on the Tape Capacity log page (0x31). This is the firmware figure,
+ /// NOT tape.sys's derived Remaining — useful to cross-check (and, if it proves more honest,
+ /// to substitute for) the driver figure near EW/EOM.
+ ///
+ /// Returns false (gracefully) on drives that do not implement the page — the drive answers
+ /// CHECK CONDITION and reports failure.
+ ///
+ ///
+ /// Receives main-partition remaining capacity in BYTES (0 if absent).
+ /// Receives main-partition maximum capacity in BYTES (0 if absent).
+ internal bool GetLtoRemainingCapacity(out long remainingBytes, out long maxCapacityBytes)
+ {
+ remainingBytes = 0;
+ maxCapacityBytes = 0;
+
+ if (!HasMedia)
+ {
+ SetError(WIN32_ERROR.ERROR_NO_MEDIA_IN_DRIVE);
+ return false;
+ }
+
+ Span cdb = stackalloc byte[10];
+ cdb[0] = c_scsiOpLogSense10;
+ cdb[2] = (byte)(0x40 | c_logPageTapeCapacity); // PC=01b (current values) | PAGE CODE 0x31
+ // bytes 3 (subpage) and 5-6 (parameter pointer) = 0
+ cdb[7] = (byte)((c_logSenseAllocLen >> 8) & 0xFF); // ALLOCATION LENGTH (BE)
+ cdb[8] = (byte)(c_logSenseAllocLen & 0xFF);
+ // byte 9 CONTROL = 0
+
+ Span data = stackalloc byte[c_logSenseAllocLen];
+ if (!SendScsiCommand(cdb, data, dataIn: true))
+ {
+ LogErrorAsTrace("Tape Capacity: LOG SENSE(10) page 0x31 failed (likely unsupported)");
+ return false;
+ }
+
+ // Log page header (SPC): byte 0 = page code, byte 1 = subpage, bytes 2-3 = PAGE LENGTH (BE),
+ // counting the parameter bytes that follow. Then a run of log parameters, each:
+ // bytes 0-1 = PARAMETER CODE (BE), byte 2 = control, byte 3 = PARAMETER LENGTH, bytes 4+ = value.
+ int pageLen = (data[2] << 8) | data[3];
+ int end = Math.Min(4 + pageLen, data.Length);
+
+ bool gotRemaining = false;
+ int p = 4;
+ while (p + 4 <= end)
+ {
+ ushort code = (ushort)((data[p] << 8) | data[p + 1]);
+ int paramLen = data[p + 3];
+ int valOff = p + 4;
+ if (valOff + paramLen > data.Length)
+ break;
+
+ long value = ReadBigEndian(data, valOff, paramLen);
+
+ if (code == c_tapeCapParamMainRemaining)
+ {
+ remainingBytes = value * c_tapeCapBytesPerUnit;
+ gotRemaining = true;
+ }
+ else if (code == c_tapeCapParamMainMaximum)
+ {
+ maxCapacityBytes = value * c_tapeCapBytesPerUnit;
+ }
+
+ p = valOff + paramLen;
+ }
+
+ if (!gotRemaining)
+ {
+ LogErrorAsTrace("Tape Capacity: main-partition remaining parameter (0x0001) not present in page");
+ return false;
+ }
+
+ m_logger.LogTrace("{Prefix}: Tape Capacity (LOG SENSE 0x31) — remaining {Rem} B, maximum {Max} B",
+ LogPrefix, remainingBytes, maxCapacityBytes);
+ ResetError();
+ return true;
+ }
+
+ // Reads a big-endian unsigned integer of 1..8 bytes from a span slice.
+ private static long ReadBigEndian(ReadOnlySpan data, int offset, int length)
+ {
+ long v = 0;
+ for (int i = 0; i < length && offset + i < data.Length; i++)
+ v = (v << 8) | data[offset + i];
+ return v;
+ }
+
+ #endregion
}
diff --git a/TapeWinNET/Controls/CalibrationCurveControl.xaml b/TapeWinNET/Controls/CalibrationCurveControl.xaml
index 115635c..53e11c8 100644
--- a/TapeWinNET/Controls/CalibrationCurveControl.xaml
+++ b/TapeWinNET/Controls/CalibrationCurveControl.xaml
@@ -17,16 +17,17 @@
+
-
-
-
+
+
+
+
+
+
-
-
diff --git a/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs b/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
index 18ee8d8..b3b7a7e 100644
--- a/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
+++ b/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
@@ -1,11 +1,11 @@
+using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
-
using Windows.Win32.System.SystemServices; // Helpers.BytesToString
-
using TapeLibNET;
namespace TapeWinNET.Controls;
@@ -13,21 +13,47 @@ namespace TapeWinNET.Controls;
///
/// Plots the calibrated ReportedRemaining → ActualRemaining curve.
///
-/// The X axis is intentionally flipped: full capacity on the left, EOM on the right.
-/// To magnify the small-but-critical EW→EOM tail, the chart uses a split axis: the span from
-/// BOT→EW occupies 80% of the width, and the EW→EOM tail occupies the remaining 20%.
-/// This preserves the overall shape while making the tail readable even when EW sits only a few
-/// percent from EOM.
+/// The axes are FLIPPED relative to the raw curve so the plot reads as "what the driver reports as a
+/// function of how much tape is truly left":
+/// X = ActualRemaining (ground truth): full capacity on the LEFT, hard EOM (0) on the RIGHT.
+/// Y = ReportedRemaining (the driver's figure): full at the TOP, 0 at the bottom.
+/// A faint identity line (Reported == Actual) makes the over/under-report gap obvious at a glance: the
+/// sudden plunge of Reported to 0 at EW (the LTO-3 "collapse") and the phantom free space still claimed
+/// at EOM (LTO-4) both show up directly against it.
+///
+///
+/// To magnify the small-but-critical EW→EOM tail, the X axis is split: the body span (capacity → EW)
+/// occupies 80% of the width on the left, and the EW→EOM tail occupies the remaining 20% on the right.
+/// This preserves the overall shape while making the tail readable even when EW sits only a few percent
+/// from EOM.
+///
+///
+/// Hovering marks the "current point" (blue): the Actual-Remaining value under the cursor is snapped
+/// onto the curve and its Actual / Reported readings appear in the top-right corner (free space, since
+/// the curve descends left-to-right). EW is marked in warning-orange, EOM in error-red.
///
///
public partial class CalibrationCurveControl : UserControl
{
private readonly Polyline _curveLine;
+ private readonly Polyline _identityLine;
private readonly Rectangle _tailShade;
private readonly Ellipse _ewMarker;
private readonly Ellipse _eomMarker;
private readonly Line _ewGuide;
+ // Hover ("current point") visuals.
+ private readonly Line _hoverGuide;
+ private readonly Ellipse _hoverDot;
+
+ // Geometry cached from the last Redraw, so the mouse handler can invert X → ActualRemaining
+ // (and place the hover dot) without recomputing the whole plot.
+ private double _bodyWidth;
+ private double _tailWidth;
+ private long _ewActual; // ActualRemaining at the EW landmark (0 when no EW); the body/tail split
+ private long _actualMax; // CapacityActual — left edge of the X axis
+ private long _reportedMax; // ReportedCapacityTotal — top of the Y axis
+
public static readonly DependencyProperty CalibrationProperty =
DependencyProperty.Register(
nameof(Calibration),
@@ -51,6 +77,15 @@ public CalibrationCurveControl()
IsHitTestVisible = false,
};
+ // Faint reference line: where the driver would sit if it reported the truth (Reported == Actual).
+ _identityLine = new Polyline
+ {
+ Stroke = new SolidColorBrush(Color.FromArgb(96, 128, 128, 128)),
+ StrokeThickness = 1,
+ StrokeDashArray = [3, 3],
+ IsHitTestVisible = false,
+ };
+
_curveLine = new Polyline
{
Stroke = WpfTheme.AccentBlueDarkBrush,
@@ -72,7 +107,7 @@ public CalibrationCurveControl()
{
Width = 8,
Height = 8,
- Fill = Brushes.DarkOrange,
+ Fill = Brushes.DarkOrange, // warning-orange: early warning
Stroke = Brushes.White,
StrokeThickness = 1,
Visibility = Visibility.Collapsed,
@@ -83,75 +118,141 @@ public CalibrationCurveControl()
{
Width = 8,
Height = 8,
- Fill = Brushes.Firebrick,
+ Fill = Brushes.Firebrick, // error-red: end of medium
+ Stroke = Brushes.White,
+ StrokeThickness = 1,
+ IsHitTestVisible = false,
+ };
+
+ _hoverGuide = new Line
+ {
+ Stroke = new SolidColorBrush(Color.FromArgb(128, 64, 64, 64)),
+ StrokeThickness = 1,
+ IsHitTestVisible = false,
+ Visibility = Visibility.Collapsed,
+ };
+
+ _hoverDot = new Ellipse
+ {
+ Width = 9,
+ Height = 9,
+ Fill = WpfTheme.AccentBlueDarkBrush, // current point: blue
Stroke = Brushes.White,
StrokeThickness = 1,
IsHitTestVisible = false,
+ Visibility = Visibility.Collapsed,
};
+ // Z-order: shade, identity, curve, guides, then markers and the hover dot on top.
PlotCanvas.Children.Add(_tailShade);
+ PlotCanvas.Children.Add(_identityLine);
PlotCanvas.Children.Add(_curveLine);
PlotCanvas.Children.Add(_ewGuide);
+ PlotCanvas.Children.Add(_hoverGuide);
PlotCanvas.Children.Add(_ewMarker);
PlotCanvas.Children.Add(_eomMarker);
+ PlotCanvas.Children.Add(_hoverDot);
PlotCanvas.SizeChanged += (_, _) => Redraw();
+ PlotCanvas.MouseMove += OnPlotMouseMove;
+ PlotCanvas.MouseLeave += OnPlotMouseLeave;
}
private static void OnCalibrationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
=> ((CalibrationCurveControl)d).Redraw();
+ #region *** Axis mapping (uses cached geometry) ***
+
+ // ActualRemaining → X. Full capacity maps to the LEFT (x = 0), EOM (0) to the RIGHT (x = w). The
+ // body span [ewActual, actualMax] occupies the left bodyWidth; the tail [0, ewActual] the right tailWidth.
+ private double MapX(long actualRemaining)
+ {
+ if (_ewActual > 0 && actualRemaining <= _ewActual)
+ {
+ double tTail = (double)actualRemaining / _ewActual; // 1 at EW, 0 at EOM
+ return _bodyWidth + (1.0 - tTail) * _tailWidth;
+ }
+
+ double topSpan = Math.Max(1L, _actualMax - _ewActual);
+ double tBody = (double)(actualRemaining - _ewActual) / topSpan; // 0 at EW, 1 at capacity
+ return (1.0 - tBody) * _bodyWidth;
+ }
+
+ // ReportedRemaining → Y. Full at the top (y = 0), 0 at the bottom (y = h).
+ private double MapY(long reportedRemaining, double h)
+ => h - ((double)reportedRemaining / Math.Max(1L, _reportedMax)) * h;
+
+ // X → ActualRemaining (inverse of MapX), for the hover readout.
+ private long InvertX(double x)
+ {
+ double w = _bodyWidth + _tailWidth;
+ x = Math.Clamp(x, 0.0, w);
+
+ if (_ewActual > 0 && x >= _bodyWidth)
+ {
+ double tTail = _tailWidth > 0 ? 1.0 - (x - _bodyWidth) / _tailWidth : 1.0;
+ return (long)(Math.Clamp(tTail, 0.0, 1.0) * _ewActual);
+ }
+
+ double tBodyFromLeft = _bodyWidth > 0 ? x / _bodyWidth : 0.0; // 0 at left (capacity), 1 at EW
+ return _ewActual + (long)((1.0 - Math.Clamp(tBodyFromLeft, 0.0, 1.0)) * (_actualMax - _ewActual));
+ }
+
+ #endregion
+
private void Redraw()
{
double w = PlotCanvas.ActualWidth;
double h = PlotCanvas.ActualHeight;
+
+ HideHover();
+
if (w < 2 || h < 2 || Calibration is null || Calibration.Curve.Count == 0)
{
_curveLine.Points.Clear();
+ _identityLine.Points.Clear();
_ewGuide.Visibility = Visibility.Collapsed;
_ewMarker.Visibility = Visibility.Collapsed;
_eomMarker.Visibility = Visibility.Collapsed;
+ _tailShade.Visibility = Visibility.Collapsed;
return;
}
ITapeCalibration calibration = Calibration;
- long reportedMax = Math.Max(1L, calibration.ReportedCapacityTotal);
- long actualMax = Math.Max(1L, calibration.CapacityActual);
- long ewReported = calibration.EarlyWarning?.ReportedRemaining ?? 0L;
+
+ _reportedMax = Math.Max(1L, calibration.ReportedCapacityTotal);
+ _actualMax = Math.Max(1L, calibration.CapacityActual);
+ _ewActual = Math.Max(0L, calibration.EarlyWarning?.ActualRemaining ?? 0L);
const double tailFraction = 0.20;
- double bodyWidth = ewReported > 0 ? w * (1.0 - tailFraction) : w;
- double tailWidth = ewReported > 0 ? w * tailFraction : 0.0;
+ _bodyWidth = _ewActual > 0 ? w * (1.0 - tailFraction) : w;
+ _tailWidth = _ewActual > 0 ? w * tailFraction : 0.0;
Point MapPoint(CalibrationPoint point)
- {
- double x;
- if (ewReported > 0 && point.ReportedRemaining <= ewReported)
- {
- double tTail = ewReported > 0 ? point.ReportedRemaining / (double)ewReported : 0.0;
- x = bodyWidth + (1.0 - tTail) * tailWidth;
- }
- else
- {
- double topSpan = Math.Max(1L, reportedMax - ewReported);
- double tBody = (point.ReportedRemaining - ewReported) / topSpan;
- x = (1.0 - tBody) * bodyWidth;
- }
-
- double y = h - ((double)point.ActualRemaining / actualMax) * h;
- return new Point(Math.Clamp(x, 0.0, w), Math.Clamp(y, 0.0, h));
- }
+ => new(Math.Clamp(MapX(point.ActualRemaining), 0.0, w),
+ Math.Clamp(MapY(point.ReportedRemaining, h), 0.0, h));
+ // The measured curve: driver-reported (Y) against true remaining (X).
_curveLine.Points = [.. calibration.Curve.Select(MapPoint)];
- ActualTopLabel.Text = Helpers.BytesToString(actualMax);
- ActualBottomLabel.Text = "0";
- ReportedLeftLabel.Text = Helpers.BytesToString(reportedMax);
- ReportedRightLabel.Text = "EOM";
+ // The identity reference at the same X positions: where Reported would equal Actual.
+ _identityLine.Points =
+ [
+ .. calibration.Curve.Select(p =>
+ new Point(Math.Clamp(MapX(p.ActualRemaining), 0.0, w),
+ Math.Clamp(MapY(p.ActualRemaining, h), 0.0, h)))
+ ];
- if (ewReported > 0 && calibration.EarlyWarning is { } ew)
+ // Axis labels: Y (left) = Reported; X (bottom) = Actual, full-capacity → EOM.
+ ReportedTopLabel.Text = Helpers.BytesToString(_reportedMax);
+ ReportedBottomLabel.Text = "0";
+ ActualLeftLabel.Text = Helpers.BytesToString(_actualMax);
+ ActualRightLabel.Text = "EOM";
+
+ if (_ewActual > 0 && calibration.EarlyWarning is { } ew)
{
var ewPoint = MapPoint(ew);
+
_ewGuide.Visibility = Visibility.Visible;
_ewGuide.X1 = ewPoint.X;
_ewGuide.X2 = ewPoint.X;
@@ -163,9 +264,9 @@ Point MapPoint(CalibrationPoint point)
Canvas.SetTop(_ewMarker, ewPoint.Y - (_ewMarker.Height / 2));
_tailShade.Visibility = Visibility.Visible;
- _tailShade.Width = tailWidth;
+ _tailShade.Width = _tailWidth;
_tailShade.Height = h;
- Canvas.SetLeft(_tailShade, bodyWidth);
+ Canvas.SetLeft(_tailShade, _bodyWidth);
Canvas.SetTop(_tailShade, 0);
}
else
@@ -175,9 +276,56 @@ Point MapPoint(CalibrationPoint point)
_tailShade.Visibility = Visibility.Collapsed;
}
- var eomPoint = MapPoint(new CalibrationPoint(0, 0));
+ // EOM sits at ActualRemaining == 0; its Y encodes the phantom free space still claimed there.
+ var eomPoint = MapPoint(new CalibrationPoint(calibration.PhantomFreeAtEom, 0));
_eomMarker.Visibility = Visibility.Visible;
Canvas.SetLeft(_eomMarker, eomPoint.X - (_eomMarker.Width / 2));
Canvas.SetTop(_eomMarker, eomPoint.Y - (_eomMarker.Height / 2));
}
+
+ #region *** Hover ("current point") ***
+
+ private void OnPlotMouseMove(object sender, MouseEventArgs e)
+ {
+ double w = PlotCanvas.ActualWidth;
+ double h = PlotCanvas.ActualHeight;
+
+ if (w < 2 || h < 2 || Calibration is null || Calibration.Curve.Count == 0)
+ {
+ HideHover();
+ return;
+ }
+
+ double x = e.GetPosition(PlotCanvas).X;
+
+ long actual = InvertX(x);
+ long reported = Calibration.TranslateActualToReported(actual); // snap onto the curve
+
+ double px = Math.Clamp(MapX(actual), 0.0, w);
+ double py = Math.Clamp(MapY(reported, h), 0.0, h);
+
+ _hoverGuide.Visibility = Visibility.Visible;
+ _hoverGuide.X1 = px;
+ _hoverGuide.X2 = px;
+ _hoverGuide.Y1 = 0;
+ _hoverGuide.Y2 = h;
+
+ _hoverDot.Visibility = Visibility.Visible;
+ Canvas.SetLeft(_hoverDot, px - (_hoverDot.Width / 2));
+ Canvas.SetTop(_hoverDot, py - (_hoverDot.Height / 2));
+
+ HoverReadout.Text = $"Actual {Helpers.BytesToString(actual)} · Reported {Helpers.BytesToString(reported)}";
+ HoverReadout.Visibility = Visibility.Visible;
+ }
+
+ private void OnPlotMouseLeave(object sender, MouseEventArgs e) => HideHover();
+
+ private void HideHover()
+ {
+ _hoverGuide.Visibility = Visibility.Collapsed;
+ _hoverDot.Visibility = Visibility.Collapsed;
+ HoverReadout.Visibility = Visibility.Collapsed;
+ }
+
+ #endregion
}
From ca582b5177a73b39579dbe5d87868450a93c0001 Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Fri, 14 Aug 2026 02:37:11 +0200
Subject: [PATCH 17/37] Update tape drive claibration to increase resolution
for the final media section (around EW). Reduce the amount of tracing from
Win32 LTO backend.
---
.../CalibrationAndLogicalEwTests.cs | 4 +-
.../Services/ServiceCalibrationTests.cs | 2 +-
TapeLibNET/OnceLatch.cs | 61 ++++++++++++++
TapeLibNET/TapeCalibration.cs | 50 +++++++++---
TapeLibNET/TapeCalibrationOptions.cs | 6 +-
TapeLibNET/TapeDrive.cs | 4 +-
TapeLibNET/TapeDriveWin32Backend.Lto.cs | 8 +-
TapeLibNET/TapeDriveWin32Backend.cs | 4 +
.../TapeDriveWin32Backend.lto-direct.cs | 26 +++---
TapeLibNET/Virtual/VirtualTapeEwProfile.cs | 80 +++++++++++++++++--
docs/Design-RemainingAndEw.md | 10 +--
11 files changed, 213 insertions(+), 42 deletions(-)
create mode 100644 TapeLibNET/OnceLatch.cs
diff --git a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
index ccb7afd..a0b8026 100644
--- a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
+++ b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
@@ -222,8 +222,8 @@ public void Apriori_ProducesConservativeUsableCurve_WithoutRun()
// Conservative: the translated actual never exceeds the reported figure at any point.
long reported = drive.Capacity;
- Assert.True(apriori.TranslateRemaining(reported) <= reported);
- Assert.True(apriori.TranslateRemaining(0) <= 0 + 1); // clamps at/near zero near EOM
+ Assert.True(apriori.TranslateReportedToActual(reported) <= reported);
+ Assert.True(apriori.TranslateReportedToActual(0) <= 0 + 1); // clamps at/near zero near EOM
}
#endregion
diff --git a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
index 62a9fe8..6062e8e 100644
--- a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
+++ b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
@@ -42,7 +42,7 @@ public async Task ExecuteCalibrateAsync_ReturnsCalibrationAndLogsSummary()
EjectWhenDone: false,
Options: new TapeCalibrationOptions
{
- SampleCount = 20,
+ SampleCount = 40,
//MinSampleInterval = 1L * MB,
//ChunkBytesTarget = 1L * MB,
}));
diff --git a/TapeLibNET/OnceLatch.cs b/TapeLibNET/OnceLatch.cs
new file mode 100644
index 0000000..f9dc39d
--- /dev/null
+++ b/TapeLibNET/OnceLatch.cs
@@ -0,0 +1,61 @@
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using System.Threading;
+
+namespace TapeLibNET;
+
+///
+/// A one-shot latch: permits a guarded side effect to run exactly ONCE per armed cycle, then stays closed
+/// until . Purpose-built to collapse repetitive per-operation logging (e.g. a SCSI sense
+/// fired on every write chunk) down to a single report per run. General-purpose and thread-safe; typically
+/// owned by a so a whole set re-arms in one place.
+///
+public sealed class OnceLatch
+{
+ private int m_fired; // 0 = armed, 1 = fired
+
+ ///
+ /// Returns exactly ONCE per armed cycle (and latches),
+ /// thereafter until . Allocates nothing, so the guarded call — and any interpolated
+ /// log message — is built only on the first occurrence, which is what makes it safe on hot paths.
+ ///
+ public bool TryEnter() => Interlocked.Exchange(ref m_fired, 1) == 0;
+
+ /// Re-arms the latch so the next fires again.
+ public void Reset() => Interlocked.Exchange(ref m_fired, 0);
+}
+
+///
+/// A set of es re-armed together, keyed by CALL SITE. Instead of declaring a field
+/// per condition, each site calls with no arguments — Roslyn fills in the caller's
+/// file and line, which identify the latch. Call at the start of each run (the same
+/// place the owner re-arms its per-run state) so every one-shot report fires afresh per run.
+///
+public sealed class OnceLatchGroup
+{
+ // Keyed on (file, line, tag) as a value tuple: the file is a compile-time constant string literal
+ // (interned, no allocation) and the tuple is a struct compared by value, so lookups on the hot path
+ // allocate nothing once the latch exists.
+ private readonly ConcurrentDictionary<(string File, int Line, string Tag), OnceLatch> m_latches = new();
+
+ // Non-capturing factory cached in a static field ⇒ no per-call delegate allocation in GetOrAdd.
+ private static readonly System.Func<(string, int, string), OnceLatch> s_factory = static _ => new OnceLatch();
+
+ ///
+ /// Returns the latch owned by THIS call site (auto-identified by +
+ /// ), creating it on first use. Pass an optional ONLY to
+ /// distinguish two independent latches that must share a single source line.
+ ///
+ public OnceLatch ThisLine(
+ string tag = "",
+ [CallerFilePath] string file = "",
+ [CallerLineNumber] int line = 0)
+ => m_latches.GetOrAdd((file, line, tag), s_factory);
+
+ /// Re-arms every latch registered so far.
+ public void ResetAll()
+ {
+ foreach (var latch in m_latches.Values)
+ latch.Reset();
+ }
+}
diff --git a/TapeLibNET/TapeCalibration.cs b/TapeLibNET/TapeCalibration.cs
index e99708e..c90ffec 100644
--- a/TapeLibNET/TapeCalibration.cs
+++ b/TapeLibNET/TapeCalibration.cs
@@ -77,13 +77,13 @@ public interface ITapeCalibration
/// clamping at the curve ends. This is the "EW-not-fired / no-EW-support" branch; the precise
/// after-EW branch is applied by using live session state.
///
- long TranslateRemaining(long reportedRemaining);
+ long TranslateReportedToActual(long reportedRemaining);
///
/// Inverse, curve-only translation ActualRemaining → ReportedRemaining (bytes), with
/// clamping at the curve ends. Answers "what would the driver report if the true remaining were
/// ?" — used to REPRODUCE a drive's optimistic remaining figure
- /// (e.g. by the virtual backend's emulation), the mirror image of .
+ /// (e.g. by the virtual backend's emulation), the mirror image of .
///
long TranslateActualToReported(long actualRemaining);
@@ -185,11 +185,16 @@ public static TapeCalibration FromMeasurements(
? a.ReportedRemaining.CompareTo(b.ReportedRemaining)
: a.ActualRemaining.CompareTo(b.ActualRemaining));
- // De-duplicate identical ReportedRemaining values, keeping the first (conservative) one.
+ // Keep one point per distinct ReportedRemaining (conservative: smallest ActualRemaining on ties) —
+ // EXCEPT the collapse tail, where ReportedRemaining pins to 0 across a real span of ActualRemaining
+ // (LTO-3). Those points are a valid Actual→Reported function and plot directly on the flipped graph,
+ // so we retain them all. The Reported→Actual lookup guards the resulting duplicate keys (see below).
var curve = new List(pts.Count);
foreach (var p in pts)
- if (curve.Count == 0 || curve[^1].ReportedRemaining != p.ReportedRemaining)
- curve.Add(p);
+ if (curve.Count == 0
+ || curve[^1].ReportedRemaining != p.ReportedRemaining
+ || p.ReportedRemaining == 0) // do NOT dedup the reported==0 collapse run
+ curve.Add(p); // De-duplicate identical ReportedRemaining values, keeping the first (conservative) one.
CalibrationPoint? ewPoint = earlyWarning is { } ew
? new CalibrationPoint(ew.ReportedRemaining, Math.Max(0L, capacityActual - ew.ActualWritten))
@@ -289,17 +294,28 @@ public static ITapeCalibration Apriori(
///
/// Translates a driver-reported remaining byte count into a more accurate actual remaining count
/// estimation, based on the calibration curve.
+ ///
+ /// Robust against the "collapse tail" some drives exhibit (LTO-3): a run of curve points that all
+ /// share ReportedRemaining == 0 while still
+ /// spans a real range. Because the curve is sorted ascending by reported (ties broken by ascending
+ /// actual), that run sits at the head as (0, 0) … (0, EwToEomDistance). A reported figure of 0
+ /// therefore clamps to the conservative (smallest) actual, and any positive reported brackets past
+ /// the whole run (lo on the last zero, hi on the first positive), so the interpolation below never
+ /// divides by a zero-width reported span.
+ ///
///
/// The remaining byte count reported by the driver.
/// The estimated actual remaining byte count.
- public long TranslateRemaining(long reportedRemaining)
+ public long TranslateReportedToActual(long reportedRemaining)
{
var c = Curve;
+
if (c.Count == 0)
return reportedRemaining; // no data → passthrough
if (reportedRemaining <= c[0].ReportedRemaining)
- return c[0].ActualRemaining; // clamp low (near EOM → conservative)
+ return c[0].ActualRemaining; // clamp low (at/near EOM, incl. a reported==0 collapse → conservative)
+
if (reportedRemaining >= c[^1].ReportedRemaining)
return c[^1].ActualRemaining; // clamp high (near BOM)
@@ -312,27 +328,38 @@ public long TranslateRemaining(long reportedRemaining)
}
CalibrationPoint a = c[lo], b = c[hi];
+
long dr = b.ReportedRemaining - a.ReportedRemaining;
if (dr <= 0)
- return a.ActualRemaining;
+ return a.ActualRemaining; // equal-reported bracket (defensive): conservative (smaller) actual
double t = (double)(reportedRemaining - a.ReportedRemaining) / dr;
return a.ActualRemaining + (long)Math.Round(t * (b.ActualRemaining - a.ActualRemaining));
}
///
- /// Inverse of : given a true ,
+ /// Inverse of : given a true ,
/// returns the (typically optimistic) figure the driver would report, by interpolating the curve
/// on its axis (monotonic non-decreasing).
+ ///
+ /// Reproduces the "collapse tail" (LTO-3) faithfully: across the run of points that share
+ /// ReportedRemaining == 0 but distinct actuals, both bracketing endpoints carry reported 0,
+ /// so this returns 0 for every actual inside the collapse zone — exactly what the drive reports
+ /// there. (Before those points were retained, this method wrongly ramped reported from 0 up to the
+ /// first post-collapse anchor.) ActualRemaining stays unique and ascending across the whole curve,
+ /// so the actual-axis span is strictly positive and never divides by zero.
+ ///
///
public long TranslateActualToReported(long actualRemaining)
{
var c = Curve;
+
if (c.Count == 0)
return actualRemaining; // no data → passthrough
if (actualRemaining <= c[0].ActualRemaining)
- return c[0].ReportedRemaining; // clamp low (near EOM)
+ return c[0].ReportedRemaining; // clamp low (at/near EOM)
+
if (actualRemaining >= c[^1].ActualRemaining)
return c[^1].ReportedRemaining; // clamp high (near BOM)
@@ -345,9 +372,10 @@ public long TranslateActualToReported(long actualRemaining)
}
CalibrationPoint a = c[lo], b = c[hi];
+
long da = b.ActualRemaining - a.ActualRemaining;
if (da <= 0)
- return a.ReportedRemaining;
+ return a.ReportedRemaining; // equal-actual bracket (defensive)
double t = (double)(actualRemaining - a.ActualRemaining) / da;
return a.ReportedRemaining + (long)Math.Round(t * (b.ReportedRemaining - a.ReportedRemaining));
diff --git a/TapeLibNET/TapeCalibrationOptions.cs b/TapeLibNET/TapeCalibrationOptions.cs
index 45306dd..0a48d6b 100644
--- a/TapeLibNET/TapeCalibrationOptions.cs
+++ b/TapeLibNET/TapeCalibrationOptions.cs
@@ -36,8 +36,8 @@ public readonly record struct TapeCalibrationOptions
/// Default value for .
public const int DefaultBlocksPerChunk = 8;
- /// Default value for — 20% of the sample budget goes to the tail.
- public const double DefaultTailSampleFraction = 0.20;
+ /// Default value for — 40% of the sample budget goes to the tail.
+ public const double DefaultTailSampleFraction = 0.40;
/// Default value for — the tail is the last 5% of capacity (or EW, whichever first).
public const double DefaultTailCapacityFraction = 0.05;
@@ -46,7 +46,7 @@ public TapeCalibrationOptions()
{
SampleCount = 1_000; // 1,000 proved good resolution for LTO drives
BlocksPerChunk = DefaultBlocksPerChunk;
- TailSampleFraction = DefaultTailSampleFraction; // reserve 20% of the budget for the EW→EOM tail
+ TailSampleFraction = DefaultTailSampleFraction; // reserve 40% of the budget for the EW→EOM tail
TailCapacityFraction = DefaultTailCapacityFraction; // tail = last 5% of capacity (or EW, whichever first)
}
diff --git a/TapeLibNET/TapeDrive.cs b/TapeLibNET/TapeDrive.cs
index 126e4e6..5a80cd7 100644
--- a/TapeLibNET/TapeDrive.cs
+++ b/TapeLibNET/TapeDrive.cs
@@ -656,7 +656,7 @@ private bool EvaluateLogicalEarlyWarning(int written, bool physicalEw)
return physicalEw;
m_bytesSinceRemainingPoll = 0L;
- long est = cal.TranslateRemaining(GetReportedContentRemaining());
+ long est = cal.TranslateReportedToActual(GetReportedContentRemaining());
m_writableHeadroomAtLastPoll = est - m_desiredEarlyWarning; // paces the next poll
return est <= m_desiredEarlyWarning || physicalEw;
}
@@ -811,7 +811,7 @@ public long EstimateActualRemaining()
return reported;
if (m_physicalEwSeen)
return Math.Max(0L, cal.EwToEomDistance - BytesAfterPhysicalEw());
- return cal.TranslateRemaining(reported);
+ return cal.TranslateReportedToActual(reported);
}
#endregion // *** Calibration ***
diff --git a/TapeLibNET/TapeDriveWin32Backend.Lto.cs b/TapeLibNET/TapeDriveWin32Backend.Lto.cs
index f9107c5..6653b18 100644
--- a/TapeLibNET/TapeDriveWin32Backend.Lto.cs
+++ b/TapeLibNET/TapeDriveWin32Backend.Lto.cs
@@ -106,6 +106,9 @@ internal void LtoClose()
m_ltoProduct = string.Empty;
m_ltoRevision = string.Empty;
+ // Re-arm all one-shot write-run reports for the next open/session.
+ m_writeRunReports.ResetAll();
+
FreeAlignedScratch();
}
@@ -1194,8 +1197,9 @@ internal bool GetLtoRemainingCapacity(out long remainingBytes, out long maxCapac
return false;
}
- m_logger.LogTrace("{Prefix}: Tape Capacity (LOG SENSE 0x31) — remaining {Rem} B, maximum {Max} B",
- LogPrefix, remainingBytes, maxCapacityBytes);
+ if (m_writeRunReports.ThisLine().TryEnter())
+ m_logger.LogTrace("{Prefix}: Tape Capacity (LOG SENSE 0x31) — remaining {Rem} B, maximum {Max} B",
+ LogPrefix, remainingBytes, maxCapacityBytes);
ResetError();
return true;
}
diff --git a/TapeLibNET/TapeDriveWin32Backend.cs b/TapeLibNET/TapeDriveWin32Backend.cs
index fb32770..dc1cc56 100644
--- a/TapeLibNET/TapeDriveWin32Backend.cs
+++ b/TapeLibNET/TapeDriveWin32Backend.cs
@@ -64,6 +64,10 @@ public partial class TapeDriveWin32Backend(ILoggerFactory loggerFactory) : TapeD
// LTO partition usage flag — set during Open via ProbeForLtoInformation(), cleared in Close
private bool m_useLtoPartitions;
+ // One-shot report latches for the LTO backend, keyed by call site and re-armed together in LtoClose().
+ // Collapses per-chunk write-flow tracing (SCSI sense, early warning, LOG SENSE) to one line per session.
+ private readonly OnceLatchGroup m_writeRunReports = new();
+
#endregion
#if DEBUG
diff --git a/TapeLibNET/TapeDriveWin32Backend.lto-direct.cs b/TapeLibNET/TapeDriveWin32Backend.lto-direct.cs
index 5ae5011..4723dfa 100644
--- a/TapeLibNET/TapeDriveWin32Backend.lto-direct.cs
+++ b/TapeLibNET/TapeDriveWin32Backend.lto-direct.cs
@@ -470,9 +470,12 @@ internal int ScsiWriteDirect(
{
programmableEarlyWarning = true;
ResetError(); // PEW is not an error
- m_logger.LogInformation(
- "{Prefix}: PROGRAMMABLE EARLY WARNING on write (accepted {Written} of {Count} bytes)",
- LogPrefix, totalWritten, count);
+
+ if (m_writeRunReports.ThisLine().TryEnter())
+ m_logger.LogInformation(
+ "{Prefix}: PROGRAMMABLE EARLY WARNING on write (accepted {Written} of {Count} bytes)",
+ LogPrefix, totalWritten, count);
+
programmableEarlyWarningReported = true;
}
@@ -496,9 +499,12 @@ internal int ScsiWriteDirect(
{
earlyWarning = true;
ResetError(); // EW is not an error
- m_logger.LogInformation(
- "{Prefix}: EARLY WARNING on write (accepted {Written} of {Count} bytes) — approaching end of partition",
- LogPrefix, totalWritten, count);
+
+ if (m_writeRunReports.ThisLine().TryEnter())
+ m_logger.LogInformation(
+ "{Prefix}: EARLY WARNING on write (accepted {Written} of {Count} bytes) — approaching end of partition",
+ LogPrefix, totalWritten, count);
+
earlyWarningReported = true;
}
@@ -654,8 +660,10 @@ internal bool ScsiWriteFilemarksDirect(int count, bool immediate, out bool early
if (r.IsProgrammableEarlyWarning || r.IsEarlyWarning)
{
earlyWarning = true;
- ResetError();
- m_logger.LogInformation("{Prefix}: EARLY WARNING while writing filemarks", LogPrefix);
+ ResetError(); // PEW / EW aren't an error
+
+ if (m_writeRunReports.ThisLine().TryEnter())
+ m_logger.LogInformation("{Prefix}: EARLY WARNING while writing filemarks", LogPrefix);
return true;
}
@@ -913,7 +921,7 @@ private unsafe ScsiDirectOutcome DecodeSptdSense(
: 0u,
};
- if (outcome.IsCheckCondition)
+ if (outcome.IsCheckCondition && m_writeRunReports.ThisLine().TryEnter())
{
m_logger.LogTrace(
"{Prefix}: {Tag} CHECK CONDITION key=0x{Key:X2} ASC=0x{Asc:X2} ASCQ=0x{Ascq:X2} " +
diff --git a/TapeLibNET/Virtual/VirtualTapeEwProfile.cs b/TapeLibNET/Virtual/VirtualTapeEwProfile.cs
index 0f4485b..2da6558 100644
--- a/TapeLibNET/Virtual/VirtualTapeEwProfile.cs
+++ b/TapeLibNET/Virtual/VirtualTapeEwProfile.cs
@@ -125,13 +125,21 @@ public static VirtualTapeEwProfile Lto4Like(
};
}
+ ///
+ /// Minimum size of the EMULATED early-warning zone produced by , no matter
+ /// how tiny the real tail scales to. Sized to one writer buffer (16 GB) so a full write operation lands
+ /// INSIDE the zone and the emulated EW / collapse / phantom behavior actually surfaces during a run.
+ /// On media smaller than this the zone is clamped to capacity — the whole cartridge becomes early warning,
+ /// which is quirky but exception-free (the user's problem to live with when emulating EW on a toy drive).
+ ///
+ public const long MinEmulatedEarlyWarningZone = 16L * 1024 * 1024 * 1024;
+
///
/// Builds an emulation profile from a real (or a-priori) , rescaling the
/// profile's (typically large) capacity onto the virtual medium's so a
/// hundreds-of-GB LTO profile can drive a small test cartridge. The reported-remaining model is derived
/// from ; the EW zone from
- /// . Both are scaled by
- /// targetCapacity / calibration.CapacityActual.
+ /// .
///
/// NOTE the DUALITY: a calibration is normally an ESTIMATION artifact, translating reported → actual
/// (). Here it is used in the opposite direction, as an
@@ -139,6 +147,16 @@ public static VirtualTapeEwProfile Lto4Like(
/// stay on the actual axis — hence is the scale
/// reference, and the fallback is the curve's own top actual anchor, never a reported figure.
///
+ ///
+ /// The EW/EOM tail is the ONLY interesting region, yet it is a physical CONSTANT (LTO-3: ~0.45 GB,
+ /// LTO-4: ~32 GB), independent of cartridge size. Scaled naively onto a small test cartridge it shrinks
+ /// to a few KB — well below one writer buffer — so the emulated behavior would never surface. We therefore
+ /// GUARANTEE the emulated EW zone spans at least , then map the
+ /// source's BODY and TAIL onto their two virtual segments INDEPENDENTLY (a piecewise-linear rescale — the
+ /// same "magnified tail" idea the calibration graph uses). Total capacity stays exact, the EW landmark and
+ /// the reported-curve shape stay consistent, and the tail is blown up enough to observe. Both actual- and
+ /// reported-remaining ride the SAME map, which is what preserves the (reported − actual) over-report.
+ ///
///
public static VirtualTapeEwProfile FromCalibration(ITapeCalibration calibration, long targetCapacity)
{
@@ -150,17 +168,65 @@ public static VirtualTapeEwProfile FromCalibration(ITapeCalibration calibration,
? calibration.CapacityActual
: calibration.Curve.Count > 0 ? calibration.Curve[^1].ActualRemaining : 0L;
- double scale = sourceCapacity > 0 ? (double)targetCapacity / sourceCapacity : 1.0;
+ // The source's EW/EOM tail, clamped into [0, sourceCapacity].
+ long sourceEwZone = System.Math.Clamp(calibration.EwToEomDistance, 0L, sourceCapacity);
- long ewZone = (long)System.Math.Round(calibration.EwToEomDistance * scale);
+ double capacityScale = sourceCapacity > 0 ? (double)targetCapacity / sourceCapacity : 1.0;
+
+ // Degenerate inputs (no source tail, or an empty medium) can't support a magnified tail — fall back to
+ // the original single-scale linear model (which reduces to the legacy passthrough when the curve is flat).
+ if (sourceEwZone <= 0 || sourceCapacity <= 0 || targetCapacity <= 0)
+ {
+ long LinearModel(long actualWritten, long cap)
+ {
+ long targetActualRemaining = System.Math.Max(0L, cap - actualWritten);
+ long sourceActualRemaining = (long)System.Math.Round(targetActualRemaining / (capacityScale == 0 ? 1.0 : capacityScale));
+ long sourceReported = calibration.TranslateActualToReported(sourceActualRemaining);
+
+ return System.Math.Max(0L, (long)System.Math.Round(sourceReported * capacityScale));
+ }
+
+ return new VirtualTapeEwProfile
+ {
+ EarlyWarningZone = (long)System.Math.Round(sourceEwZone * capacityScale),
+ ReportedRemainingModel = LinearModel,
+ };
+ }
+
+ // Floor the emulated EW zone to one writer buffer so it is observable, but never past capacity (a
+ // sub-buffer cartridge simply becomes all-EW). All four segment lengths below are >= 1, so the
+ // piecewise maps can never divide by zero.
+ long ewZone = System.Math.Clamp(
+ System.Math.Max(
+ (long)System.Math.Round(sourceEwZone * capacityScale),
+ System.Math.Min(MinEmulatedEarlyWarningZone, targetCapacity)),
+ 0L, targetCapacity);
+
+ long targetBody = System.Math.Max(1L, targetCapacity - ewZone);
+ long sourceBody = System.Math.Max(1L, sourceCapacity - sourceEwZone);
+
+ // Piecewise-linear remaining maps. Tail segment [0, ewZone] ↔ source [0, sourceEwZone] (magnified);
+ // body segment the rest. Reported and actual are both byte counts on the SAME remaining axis, so BOTH
+ // ride these maps — that is what keeps the (reported − actual) over-report faithful after rescaling.
+ long ToSource(long virtualRemaining)
+ => virtualRemaining <= ewZone
+ ? (long)System.Math.Round((double)virtualRemaining / ewZone * sourceEwZone)
+ : sourceEwZone + (long)System.Math.Round((double)(virtualRemaining - ewZone) / targetBody * sourceBody);
+
+ long ToVirtual(long sourceRemaining)
+ => sourceRemaining <= sourceEwZone
+ ? (long)System.Math.Round((double)sourceRemaining / sourceEwZone * ewZone)
+ : ewZone + (long)System.Math.Round((double)(sourceRemaining - sourceEwZone) / sourceBody * targetBody);
long Model(long actualWritten, long cap)
{
- // Map the virtual position back onto the source profile's scale, translate, then scale back.
long targetActualRemaining = System.Math.Max(0L, cap - actualWritten);
- long sourceActualRemaining = (long)System.Math.Round(targetActualRemaining / (scale == 0 ? 1.0 : scale));
+
+ // Virtual → source (magnified tail), translate on the source curve, then source → virtual.
+ long sourceActualRemaining = ToSource(targetActualRemaining);
long sourceReported = calibration.TranslateActualToReported(sourceActualRemaining);
- return (long)System.Math.Round(sourceReported * scale);
+
+ return System.Math.Max(0L, ToVirtual(sourceReported));
}
return new VirtualTapeEwProfile
diff --git a/docs/Design-RemainingAndEw.md b/docs/Design-RemainingAndEw.md
index 1bdfd85..23764d5 100644
--- a/docs/Design-RemainingAndEw.md
+++ b/docs/Design-RemainingAndEw.md
@@ -203,7 +203,7 @@ compares a profile key); the concrete type is JSON-serialized inside TapeLibNET.
| `Curve` | `ReportedRemaining → ActualRemaining` points, sorted ascending, conservative on ties. |
| `EarlyWarning` | Nullable `(ReportedRemaining, ActualRemaining)` landmark; null if the drive never reported EW. |
| `EwToEomDistance` | The landmark's `ActualRemaining` — the stable per-profile constant for tail byte-counting. |
-| `TranslateRemaining(reported)` | Pure curve-only translation with end clamping (the before-EW / no-EW branch). |
+| `TranslateReportedToActual(reported)` | Pure curve-only translation with end clamping (the before-EW / no-EW branch). |
| `SaveTo(stream)` | Writes the opaque JSON blob the app persists verbatim. |
Factories: `FromMeasurements(...)` (a run), `Apriori(capacity, marginPercent=5, remainingAtEwPercent=7)`
@@ -359,7 +359,7 @@ Phase 2 and only needs a stub so the `Write` signature stays honest.)
figure overshoots toward the tail as the real LTO-4 does. Truthful anchors ⇒ exact
`capacity − bytesWritten`.
- Leverage the existing `ITapeCalibration` mechanism so both synthetic (`Apriori`) and real-life measured
- calibration data can drive the emulation, flipping `TranslateRemaining()` into
+ calibration data can drive the emulation, flipping `TranslateReportedToActual()` into
`TranslateActualToReported()`. A catch to address: real-life profiles originate from large-capacity media,
100s GB, so a wrapper maps the profile's original capacity onto the generally much smaller virtual drive.
- **`WriteBlocks` / `Write` semantics:**
@@ -419,7 +419,7 @@ behavior. Key design points:
#### `ITapeCalibration.TranslateActualToReported` (added to `TapeCalibration.cs`)
-The inverse of `TranslateRemaining`: given a true `ActualRemaining`, return the (optimistic) figure the driver
+The inverse of `TranslateReportedToActual`: given a true `ActualRemaining`, return the (optimistic) figure the driver
would report, by interpolating the same curve on its `ActualRemaining` axis (monotonic non-decreasing), with
end clamping. This is the one new library API the emulation needed; it is also generally useful (e.g. "what
would the driver claim here?").
@@ -547,7 +547,7 @@ memory-backed virtual cartridge carrying an LTO-4-like EW profile.
reproduces every field (format id, profile key, both capacities, `EwToEomDistance`, and every curve
point); a blob with an unrecognized `FormatId` is rejected (`LoadFrom` returns `null`).
- **`Apriori` baseline** — `Apriori_ProducesConservativeUsableCurve_WithoutRun`: produces a usable,
- conservative curve with no run (`TranslateRemaining` never exceeds the reported figure).
+ conservative curve with no run (`TranslateReportedToActual` never exceeds the reported figure).
- **Multi-profile auto-selection** — `MultiProfile_SelectsMatchingKey_AndTracksLoadUnload`: a
non-matching key is not selected; a matching one is; a **snapshot round-trip** (`CaptureMemorySnapshot`
→ `UnloadMedia` → `InsertMemoryMedia` → `ReloadMedia` → `PrepareMedia`) re-runs `SelectCalibration` and
@@ -880,7 +880,7 @@ The Open Virtual Drive dialog exposes both axes with the shared %/MB/GB unit sel
is usually left alone).
`ITapeCalibration` is deliberately used in **two opposite directions**, and both are documented as such: as
-an *estimation* artifact (`TranslateRemaining`: reported → actual, at runtime) and as an *emulation* source
+an *estimation* artifact (`TranslateReportedToActual`: reported → actual, at runtime) and as an *emulation* source
(`VirtualTapeEwProfile.FromCalibration` / `TranslateActualToReported`: actual → reported, for replaying a
measured drive on a virtual one).
From c297d93c2b8574ad62d9c2e8711d33bc7dfa8038 Mon Sep 17 00:00:00 2001
From: Alex K
Date: Fri, 14 Aug 2026 02:38:04 +0200
Subject: [PATCH 18/37] Update calibration process UI messaging.
---
TapeLibNET/Services/ServiceOperationProgressHandler.cs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/TapeLibNET/Services/ServiceOperationProgressHandler.cs b/TapeLibNET/Services/ServiceOperationProgressHandler.cs
index 7adcb29..09a69dd 100644
--- a/TapeLibNET/Services/ServiceOperationProgressHandler.cs
+++ b/TapeLibNET/Services/ServiceOperationProgressHandler.cs
@@ -452,8 +452,9 @@ public CalibrateResult GenerateResult(
private static string FormatPhase(string phase) => phase switch
{
- "sampling" => "Writing to EOM",
- "early-warning" => "Capturing EW landmark",
+ "sampling" => "Writing to the main media section",
+ "sampling-tail" => "Writing to the final media section",
+ "early-warning" => "Capturing early-warning landmark",
"eom" => "Finalizing calibration",
_ => string.IsNullOrWhiteSpace(phase) ? "Calibrating" : phase,
};
From 3d038f050716bb3648a8998106dd8dd64675bbc2 Mon Sep 17 00:00:00 2001
From: Alex K
Date: Sun, 16 Aug 2026 03:21:30 +0200
Subject: [PATCH 19/37] Add calibration profile browsing / viewing / removing
UI.
---
TapeWinNET/CalibrationProfilesWindow.xaml | 154 +++++++++++++++++
TapeWinNET/CalibrationProfilesWindow.xaml.cs | 52 ++++++
TapeWinNET/MainWindow.xaml | 1 +
.../CalibrationProfilesViewModel.cs | 157 ++++++++++++++++++
.../ViewModels/MainViewModel.Calibration.cs | 13 ++
docs/Design-RemainingAndEw.md | 27 +++
6 files changed, 404 insertions(+)
create mode 100644 TapeWinNET/CalibrationProfilesWindow.xaml
create mode 100644 TapeWinNET/CalibrationProfilesWindow.xaml.cs
create mode 100644 TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs
diff --git a/TapeWinNET/CalibrationProfilesWindow.xaml b/TapeWinNET/CalibrationProfilesWindow.xaml
new file mode 100644
index 0000000..066ddbc
--- /dev/null
+++ b/TapeWinNET/CalibrationProfilesWindow.xaml
@@ -0,0 +1,154 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TapeWinNET/CalibrationProfilesWindow.xaml.cs b/TapeWinNET/CalibrationProfilesWindow.xaml.cs
new file mode 100644
index 0000000..6888c73
--- /dev/null
+++ b/TapeWinNET/CalibrationProfilesWindow.xaml.cs
@@ -0,0 +1,52 @@
+using System.Windows;
+
+using TapeWinNET.Help;
+using TapeWinNET.ViewModels;
+
+namespace TapeWinNET;
+
+public partial class CalibrationProfilesWindow : Window, IHelpPaneHost
+{
+ private readonly DialogHelpPaneController _help;
+
+ public CalibrationProfilesWindow(CalibrationProfilesViewModel viewModel)
+ {
+ InitializeComponent();
+ DataContext = viewModel;
+
+ var icon = TapeIcons.GetTapeMediaIcon(large: true);
+ if (icon != null)
+ {
+ icon.Freeze();
+ Icon = icon;
+ }
+
+ _help = new DialogHelpPaneController(
+ this, this, HelpPaneColumn, HelpPaneSplitter, HelpPaneControl,
+ defaultTopicId: "dialog.calibration-profiles", helpButton: HelpButton);
+ }
+
+ private void HelpButton_Click(object sender, RoutedEventArgs e)
+ => _help.ToggleHelpPane();
+
+ private void Window_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
+ => _help.HandleF1(e);
+
+ #region IHelpPaneHost
+
+ public string HostName => nameof(CalibrationProfilesWindow);
+
+ public HelpPaneHostMode HostMode => HelpPaneHostMode.Adjacent;
+
+ public void OnPaneOpening(double desiredWidth) => _help.OnPaneOpening(desiredWidth);
+
+ public void OnPaneClosed() => _help.OnPaneClosed();
+
+ public FrameworkElement? ResolveControlByName(string name)
+ => FindName(name) as FrameworkElement;
+
+ public void OpenHelpPane(string? topicId = null) => _help.OpenHelpPane(topicId);
+ public string? GetDefaultTopicId() => _help.GetDefaultTopicId();
+
+ #endregion
+}
diff --git a/TapeWinNET/MainWindow.xaml b/TapeWinNET/MainWindow.xaml
index 331a6e3..35d90c2 100644
--- a/TapeWinNET/MainWindow.xaml
+++ b/TapeWinNET/MainWindow.xaml
@@ -290,6 +290,7 @@
+
diff --git a/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs b/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs
new file mode 100644
index 0000000..4fb9cc5
--- /dev/null
+++ b/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs
@@ -0,0 +1,157 @@
+using System.Collections.ObjectModel;
+using System.Windows;
+using System.Windows.Input;
+
+using Windows.Win32.System.SystemServices; // Helpers.BytesToStringLong
+
+using TapeLibNET;
+using TapeWinNET.Services;
+
+namespace TapeWinNET.ViewModels;
+
+///
+/// ViewModel for the "Calibration Profiles..." browser window (Media menu). Lists every
+/// calibration profile previously persisted to , lets the
+/// user inspect one, apply it to the currently loaded media, or remove it from the store.
+///
+public sealed class CalibrationProfilesViewModel : ViewModelBase
+{
+ private readonly TapeService _tapeService;
+ private readonly Func _isBusy;
+ private ITapeCalibration? _selectedProfile;
+ private string _statusMessage = string.Empty;
+
+ public CalibrationProfilesViewModel(TapeService tapeService, Func isBusy)
+ {
+ _tapeService = tapeService;
+ _isBusy = isBusy;
+
+ ApplyCommand = new RelayCommand(_ => Apply(), _ => CanApply);
+ RemoveCommand = new RelayCommand(_ => Remove(), _ => SelectedProfile is not null);
+
+ Reload();
+ }
+
+ #region Profiles
+
+ public ObservableCollection Profiles { get; } = [];
+
+ public ITapeCalibration? SelectedProfile
+ {
+ get => _selectedProfile;
+ set
+ {
+ if (!SetProperty(ref _selectedProfile, value))
+ return;
+
+ OnPropertyChanged(nameof(HasSelection));
+ OnPropertyChanged(nameof(ReportedCapacityAtBomDisplay));
+ OnPropertyChanged(nameof(PhantomFreeAtEomDisplay));
+ OnPropertyChanged(nameof(CapacityActualDisplay));
+ OnPropertyChanged(nameof(EarlyWarningDisplay));
+ OnPropertyChanged(nameof(EwToEomDistanceDisplay));
+ OnPropertyChanged(nameof(CurvePointCountDisplay));
+ StatusMessage = string.Empty;
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+
+ public bool HasSelection => SelectedProfile is not null;
+
+ public string ReportedCapacityAtBomDisplay =>
+ SelectedProfile is not null ? Helpers.BytesToStringLong(SelectedProfile.ReportedCapacityAtBom) : "—";
+
+ public string PhantomFreeAtEomDisplay =>
+ SelectedProfile is not null ? Helpers.BytesToStringLong(SelectedProfile.PhantomFreeAtEom) : "—";
+
+ public string CapacityActualDisplay =>
+ SelectedProfile is not null ? Helpers.BytesToStringLong(SelectedProfile.CapacityActual) : "—";
+
+ public string EarlyWarningDisplay =>
+ SelectedProfile?.EarlyWarning is { } ew
+ ? $"{Helpers.BytesToStringLong(ew.ActualRemaining)} remaining (reported {Helpers.BytesToStringLong(ew.ReportedRemaining)})"
+ : "Not observed";
+
+ public string EwToEomDistanceDisplay =>
+ SelectedProfile is not null && SelectedProfile.EwToEomDistance > 0
+ ? Helpers.BytesToStringLong(SelectedProfile.EwToEomDistance)
+ : "—";
+
+ public string CurvePointCountDisplay =>
+ SelectedProfile is not null ? SelectedProfile.Curve.Count.ToString("N0") : "0";
+
+ public string StatusMessage
+ {
+ get => _statusMessage;
+ private set => SetProperty(ref _statusMessage, value);
+ }
+
+ #endregion
+
+ #region Commands
+
+ public ICommand ApplyCommand { get; }
+ public ICommand RemoveCommand { get; }
+
+ private bool CanApply =>
+ SelectedProfile is not null && !_isBusy() && _tapeService.IsMediaLoaded;
+
+ #endregion
+
+ #region Operations
+
+ private void Reload()
+ {
+ var selectedKey = SelectedProfile?.ProfileKey;
+
+ Profiles.Clear();
+ foreach (var profile in App.Settings.Calibrations.LoadAll())
+ Profiles.Add(profile);
+
+ SelectedProfile = selectedKey is null
+ ? Profiles.FirstOrDefault()
+ : Profiles.FirstOrDefault(p => p.ProfileKey == selectedKey) ?? Profiles.FirstOrDefault();
+ }
+
+ private void Apply()
+ {
+ if (SelectedProfile is null)
+ return;
+
+ bool matched = _tapeService.AddCalibration(SelectedProfile);
+ StatusMessage = matched
+ ? "Calibration profile applied to the current media."
+ : "Calibration profile loaded, but it does not match the current media.";
+ }
+
+ private void Remove()
+ {
+ if (SelectedProfile is null)
+ return;
+
+ var result = SimpleBox.Show(
+ $"Remove the calibration profile '{SelectedProfile.ProfileKey}'?\n\nThis cannot be undone.",
+ "Remove Calibration Profile",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning);
+
+ if (result != MessageBoxResult.Yes)
+ return;
+
+ if (!App.Settings.Calibrations.Delete(SelectedProfile.ProfileKey))
+ {
+ SimpleBox.Show(
+ $"Failed to remove the calibration profile.\n\n{App.Settings.Calibrations.LastErrorMessage}",
+ "Remove Calibration Profile",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ return;
+ }
+
+ SelectedProfile = null;
+ Reload();
+ StatusMessage = "Calibration profile removed.";
+ }
+
+ #endregion
+}
diff --git a/TapeWinNET/ViewModels/MainViewModel.Calibration.cs b/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
index 896d255..daef6d6 100644
--- a/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
@@ -86,11 +86,13 @@ public bool IsAbortCalibrationEnabled
public ICommand CalibrateMediaCommand { get; private set; } = null!;
public ICommand AbortCalibrationCommand { get; private set; } = null!;
+ public ICommand ShowCalibrationProfilesCommand { get; private set; } = null!;
private void InitializeCalibrationCommands()
{
CalibrateMediaCommand = new RelayCommand(ShowCalibrationWindow, _ => !IsBusy && _tapeService.IsMediaLoaded);
AbortCalibrationCommand = new RelayCommand(AbortCalibration, _ => IsCalibrateInProgress);
+ ShowCalibrationProfilesCommand = new RelayCommand(ShowCalibrationProfilesWindow);
}
#endregion
@@ -112,6 +114,17 @@ private void ShowCalibrationWindow(object? parameter)
window.ShowDialog();
}
+ private void ShowCalibrationProfilesWindow(object? parameter)
+ {
+ var viewModel = new CalibrationProfilesViewModel(_tapeService, () => IsBusy);
+
+ var window = new CalibrationProfilesWindow(viewModel)
+ {
+ Owner = Application.Current.MainWindow
+ };
+ window.ShowDialog();
+ }
+
private void OnStartCalibration(CalibrationViewModel viewModel)
{
Application.Current.Windows.OfType().FirstOrDefault()?.Close();
diff --git a/docs/Design-RemainingAndEw.md b/docs/Design-RemainingAndEw.md
index 23764d5..2bb12be 100644
--- a/docs/Design-RemainingAndEw.md
+++ b/docs/Design-RemainingAndEw.md
@@ -788,6 +788,33 @@ The calibration chart keeps its plot/axis labels inside the GroupBox content are
records both over-report anchors of a virtual-media run — `ReportedCapacityAtBom` and `PhantomFreeAtEom` —
with regression coverage over the non-zero cases of each.
+- **Follow-up: Calibration Profiles browser (`Media | Calibration Profiles...`)**
+
+ Gap: `CalibrationWindow` only ever surfaces the profile just measured, from the one-shot Calibrate flow.
+ Once closed, a saved profile is invisible again — the user has no way to review, re-apply, or discard a
+ previously calibrated drive+media profile without re-running calibration. Fixed by adding a standalone
+ browser, reachable independently of the destructive Calibrate workflow:
+
+ - **`CalibrationProfilesViewModel`** loads every persisted profile via `TapeCalibrationStore.LoadAll()`
+ (the same shared, library-scoped store used by Save/Apply in `CalibrationViewModel`) into an
+ `ObservableCollection`. Selecting a profile drives the same display properties
+ (`CapacityActualDisplay`, `EarlyWarningDisplay`, `EwToEomDistanceDisplay`, etc.) used by the
+ result window, so the curve control and stat layout are visually consistent.
+ - **`ApplyCommand`** calls `TapeService.AddCalibration()` on the selected profile — identical to the
+ result window's *Apply Profile* action — but is gated on `!IsBusy && IsMediaLoaded` (passed in as a
+ `Func isBusy` delegate from `MainViewModel`, since the VM only holds a `TapeService`, not the
+ main VM's busy state) so it is disabled whenever no media is loaded or another operation is running.
+ - **`RemoveCommand`** confirms via `SimpleBox` (Yes/No, Warning icon) before calling
+ `TapeCalibrationStore.Delete()`, then reloads the list and clears the selection.
+ - **`CalibrationProfilesWindow`** reuses the `CalibrationWindow` layout almost verbatim — same
+ "Measured Result" group box and `CalibrationCurveControl` — but replaces the single profile summary
+ with a `ComboBox` bound to `Profiles`/`SelectedProfile` at the top, and swaps *Save Profile* / *Apply
+ Profile* for *Apply* / *Remove*. Same help-pane wiring pattern as the other dialogs (own topic id,
+ `dialog.calibration-profiles`).
+ - **Entry point:** a new `ShowCalibrationProfilesCommand` on `MainViewModel` (always enabled — browsing
+ and removal don't require loaded media, only Apply does) opens the window from a new
+ "Calibration _Profiles..." item on the `_Media` menu, right after "_Calibrate...".
+
### Phase 6 — `TapeConNET` (CLI)
- **Reporting:** the calibrated `WritableRemaining` flows automatically through the Service layer; ensure any status
From e83939b6d2651903c9545d93af7f5c11764fc380 Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Sun, 16 Aug 2026 03:25:06 +0200
Subject: [PATCH 20/37] Make including LtoRemaining (SCSI sense) with
calibration optional; off by default. Trace exceptions in
PresentationCore.dll.
---
TapeLibNET/TapeCalibrationOptions.cs | 10 ++++++++++
TapeLibNET/TapeCalibrator.cs | 3 ++-
TapeWinNET/App.xaml.cs | 10 ++++++++++
3 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/TapeLibNET/TapeCalibrationOptions.cs b/TapeLibNET/TapeCalibrationOptions.cs
index 0a48d6b..875c374 100644
--- a/TapeLibNET/TapeCalibrationOptions.cs
+++ b/TapeLibNET/TapeCalibrationOptions.cs
@@ -33,6 +33,14 @@ public readonly record struct TapeCalibrationOptions
///
public double TailCapacityFraction { get; init; }
+ ///
+ /// EXPERIMENTAL: capture the drive's own LOG SENSE 0x31 remaining alongside the driver figure into
+ /// . LTO-3/4/6 runs proved it EQUALS the driver value
+ /// (LTO-4/6) or collapses identically (LTO-3), so it carries no independent signal — hence default
+ /// . Flip on only to re-verify on a new drive/generation.
+ ///
+ public bool CaptureLtoRemaining { get; init; }
+
/// Default value for .
public const int DefaultBlocksPerChunk = 8;
@@ -48,6 +56,8 @@ public TapeCalibrationOptions()
BlocksPerChunk = DefaultBlocksPerChunk;
TailSampleFraction = DefaultTailSampleFraction; // reserve 40% of the budget for the EW→EOM tail
TailCapacityFraction = DefaultTailCapacityFraction; // tail = last 5% of capacity (or EW, whichever first)
+
+ CaptureLtoRemaining = false; // proven redundant across LTO-3/4/6 — off by default
}
/// Turn caller intent into a concrete, always-valid plan for this drive.
diff --git a/TapeLibNET/TapeCalibrator.cs b/TapeLibNET/TapeCalibrator.cs
index 7a4e42e..9c2579f 100644
--- a/TapeLibNET/TapeCalibrator.cs
+++ b/TapeLibNET/TapeCalibrator.cs
@@ -218,8 +218,9 @@ private bool CheckForAbort()
// EXPERIMENTAL: probe the drive's own remaining figure over SCSI (LOG SENSE 0x31), alongside the
// driver-reported one, so we can decide offline whether it dodges the tail quirks (esp. the LTO-3
// collapse). Only meaningful on a Win32 LTO backend; a no-op (−1) otherwise.
+ // Proven redundant across LTO-3/4/6, so off by default in Options. Can re-enable for new models
TapeDriveWin32Backend? ltoBackend = Drive.Backend as TapeDriveWin32Backend;
- bool probeLto = ltoBackend?.IsLto == true;
+ bool probeLto = Options.CaptureLtoRemaining && ltoBackend?.IsLto == true;
m_logger.LogInformation(
"{Prefix}: Calibration start — profile '{Key}', reportedCapacityAtBom {Cap}, blockSize {Bs}, " +
diff --git a/TapeWinNET/App.xaml.cs b/TapeWinNET/App.xaml.cs
index 591f916..1fbd53e 100644
--- a/TapeWinNET/App.xaml.cs
+++ b/TapeWinNET/App.xaml.cs
@@ -97,6 +97,16 @@ protected override void OnStartup(StartupEventArgs e)
typeof(Window),
FrameworkElement.LoadedEvent,
new RoutedEventHandler(OnWindowLoaded));
+
+ // trace exception in PresentationCore.dll
+ AppDomain.CurrentDomain.FirstChanceException += (_, e) =>
+ {
+ if (e.Exception is ArgumentException
+ && e.Exception.TargetSite?.Module.Name == "PresentationCore.dll")
+ {
+ System.Diagnostics.Debug.WriteLine($"[FCE] {e.Exception.Message}\n{e.Exception.StackTrace}");
+ }
+ };
}
protected override async void OnExit(ExitEventArgs e)
From 313ed4eed82b902b5ace8f8eba25e88c69195162 Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Sun, 16 Aug 2026 23:38:43 +0200
Subject: [PATCH 21/37] Implement calibration restart capability in
TapeCalibrator. Fix the issue with resuming writing to virtual media.
---
TapeLibNET.Tests/CalibrationResumeTests.cs | 340 ++++++++
.../Services/ServiceCalibrationTests.cs | 4 +-
TapeLibNET.Tests/VirtualDriveBasicTests.cs | 135 +++-
TapeLibNET/TapeCalibrationCheckpoint.cs | 280 +++++++
TapeLibNET/TapeCalibrationOptions.cs | 39 +-
TapeLibNET/TapeCalibrator.cs | 750 ++++++++++++++----
TapeLibNET/Virtual/VirtualTapeMedia.cs | 29 +-
7 files changed, 1394 insertions(+), 183 deletions(-)
create mode 100644 TapeLibNET.Tests/CalibrationResumeTests.cs
create mode 100644 TapeLibNET/TapeCalibrationCheckpoint.cs
diff --git a/TapeLibNET.Tests/CalibrationResumeTests.cs b/TapeLibNET.Tests/CalibrationResumeTests.cs
new file mode 100644
index 0000000..9b51a50
--- /dev/null
+++ b/TapeLibNET.Tests/CalibrationResumeTests.cs
@@ -0,0 +1,340 @@
+using System;
+using System.Collections.Generic;
+using TapeLibNET.Virtual;
+
+namespace TapeLibNET.Tests;
+
+///
+/// Coverage for the RESUMABLE calibration feature ( /
+/// ) and its on-tape record framing
+/// (), driven over small memory-backed virtual cartridges.
+///
+/// Two tiers:
+///
+/// BACKEND-INDEPENDENT record round-trip / CRC tests — pure serialization, no drive.
+/// END-TO-END resume / recalibrate tests — these exercise backend behaviors the earlier
+/// calibration tests never did: BACKWARD filemark spacing (MoveToNextFilemark(-n)),
+/// SEEK-TO-EOD (FastforwardToEnd), and OVERWRITE-truncates-to-new-EOD after a backward
+/// seek. A failure here may indicate a gap in the virtual backend's tape emulation, not the
+/// calibrator.
+///
+///
+///
+public class CalibrationResumeTests
+{
+ // 64 MB content — small enough for memory speed, large enough for several body checkpoints.
+ private const long Capacity = 64L * 1024 * 1024;
+
+ #region *** Helpers ***
+
+ private static (TapeDrive Drive, VirtualTapeDriveBackend Backend) CreateDrive(
+ VirtualTapeEwProfile? profile, long capacity = Capacity)
+ {
+ var backend = VirtualTapeDriveBackend.CreateMemoryBacked(
+ Helpers.TestLoggerFactory.Default,
+ VirtualTapeDriveCapabilities.WithFilemarksOnlyLargeBlocks,
+ contentCapacity: capacity,
+ initiatorPartitionCapacity: 0);
+
+ backend.IoRate = VirtualTapeDriveIoRate.Unlimited;
+ backend.EmulatedEarlyWarning = profile;
+
+ var drive = new TapeDrive(Helpers.TestLoggerFactory.Default, backend);
+ Assert.True(drive.ReopenDrive(0), "Failed to open virtual drive");
+ Assert.True(drive.ReloadMedia(), "Failed to load virtual media");
+ Assert.True(drive.PrepareMedia(), "Failed to prepare virtual media");
+ return (drive, backend);
+ }
+
+ // Fast run options with FINE checkpointing (16 body checkpoints across the medium) so an
+ // interruption partway through always leaves several recoverable checkpoints on tape.
+ private static TapeCalibrationOptions FastOptions(int numCheckpoints = 16) => new()
+ {
+ SampleCount = 40,
+ NumCheckpoints = numCheckpoints,
+ };
+
+ ///
+ /// Progress sink that flips the calibrator's abort flag once bytes-written crosses a threshold —
+ /// a DETERMINISTIC stand-in for a mid-run transport failure (progress fires synchronously in the
+ /// write loop, so the next CheckForAbort observes it).
+ ///
+ private sealed class AbortAfterBytes(TapeCalibrator calibrator, long thresholdBytes)
+ : IProgress
+ {
+ public long LastBytesWritten { get; private set; }
+ public bool Fired { get; private set; }
+
+ public void Report(TapeCalibrationProgress p)
+ {
+ LastBytesWritten = p.BytesWritten;
+
+ if (!Fired && p.BytesWritten >= thresholdBytes)
+ {
+ Fired = true;
+ calibrator.IsAbortRequested = true;
+ }
+ }
+ }
+
+ private static void AssertCurveWellFormed(ITapeCalibration cal)
+ {
+ Assert.True(cal.Curve.Count >= 2, "Curve should have at least two points");
+ for (int i = 1; i < cal.Curve.Count; i++)
+ {
+ Assert.True(cal.Curve[i].ReportedRemaining >= cal.Curve[i - 1].ReportedRemaining,
+ "ReportedRemaining axis must be ascending");
+ Assert.True(cal.Curve[i].ActualRemaining >= cal.Curve[i - 1].ActualRemaining,
+ "ActualRemaining must be monotonic non-decreasing");
+ }
+ }
+
+ #endregion
+
+ #region *** Record framing (backend-independent) ***
+
+ [Fact]
+ public void CheckpointRecord_PackUnpack_RoundTrips_AndCrcDetectsCorruption()
+ {
+ var runId = Guid.NewGuid();
+ var samples = new List<(long ActualWritten, long ReportedRemaining)>
+ {
+ (0L, 1000L), (100L, 900L), (200L, 800L),
+ };
+ var cp = new TapeCalibrationCheckpoint(runId, Index: 3, BytesWritten: 200L,
+ EarlyWarning: (150L, 850L), Samples: samples);
+
+ byte[] frame = TapeCalibrationRecord.Pack(cp);
+
+ var back = TapeCalibrationRecord.Unpack(frame, frame.Length);
+ Assert.NotNull(back);
+ Assert.Equal(runId, back!.RunId);
+ Assert.Equal(3, back.Index);
+ Assert.Equal(200L, back.BytesWritten);
+ Assert.Equal((150L, 850L), back.EarlyWarning);
+ Assert.Equal(3, back.Samples.Count);
+ Assert.Equal(samples[1], back.Samples[1]);
+
+ // Flip a byte INSIDE the payload (past the 4-byte length prefix) ⇒ CRC catches it ⇒ null.
+ byte[] corrupt = (byte[])frame.Clone();
+ corrupt[8] ^= 0xFF;
+ Assert.Null(TapeCalibrationRecord.Unpack(corrupt, corrupt.Length));
+ }
+
+ [Fact]
+ public void CheckpointRecord_WithoutEarlyWarning_RoundTrips()
+ {
+ var runId = Guid.NewGuid();
+ var cp = new TapeCalibrationCheckpoint(runId, Index: 0, BytesWritten: 0L,
+ EarlyWarning: null, Samples: new List<(long, long)> { (0L, 500L) });
+
+ byte[] frame = TapeCalibrationRecord.Pack(cp);
+ var back = TapeCalibrationRecord.Unpack(frame, frame.Length);
+
+ Assert.NotNull(back);
+ Assert.Null(back!.EarlyWarning);
+ Assert.Single(back.Samples);
+ }
+
+ [Fact]
+ public void HeaderRecord_PackUnpack_RoundTrips()
+ {
+ var runId = Guid.NewGuid();
+ var plan = new TapeCalibrationPlan(
+ SampleCount: 1000, BodySampleCount: 600, TailSampleCount: 400,
+ BlockSize: (uint)(1 << 20), BlocksPerChunk: 8, ChunkSize: 8 << 20,
+ TailBlocksPerChunk: 1, TailChunkSize: 1 << 20,
+ TailCapacityFraction: 0.05, NumCheckpoints: 128);
+
+ var header = new TapeCalibrationRunHeader(
+ runId, "VENDOR|PRODUCT|REV|64MB", CapacityReportedAtBom: 12345L,
+ BlockSize: (uint)(1 << 20), StartedUtc: DateTime.UtcNow, Plan: plan);
+
+ byte[] frame = TapeCalibrationRecord.Pack(header);
+ var back = TapeCalibrationRecord.Unpack(frame, frame.Length);
+
+ Assert.NotNull(back);
+ Assert.Equal(runId, back!.RunId);
+ Assert.Equal("VENDOR|PRODUCT|REV|64MB", back.ProfileKey);
+ Assert.Equal(12345L, back.CapacityReportedAtBom);
+ Assert.Equal(plan.NumCheckpoints, back.Plan.NumCheckpoints);
+ Assert.Equal(plan.TailCapacityFraction, back.Plan.TailCapacityFraction);
+ Assert.Equal(plan.SampleCount, back.Plan.SampleCount);
+ }
+
+ [Fact]
+ public void Unpack_OfForeignBlock_ReturnsNull()
+ {
+ // A block of random bytes is not one of our records: no valid signature / length ⇒ null.
+ var junk = new byte[4096];
+ new Random(7).NextBytes(junk);
+ Assert.Null(TapeCalibrationRecord.Unpack(junk, junk.Length));
+ }
+
+ #endregion
+
+ #region *** Resume (end-to-end) ***
+
+ [Fact]
+ public void Resume_ContinuesAbortedRun_ToCompletion()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ // Simulate an interruption ~halfway — several body checkpoints are on tape by then.
+ var run = new TapeCalibrator(drive) { Options = FastOptions() };
+ var abort = new AbortAfterBytes(run, Capacity / 2);
+ ITapeCalibration? aborted = run.Run(abort);
+
+ Assert.Null(aborted); // the run was interrupted before EOM
+ Assert.True(abort.Fired);
+
+ // Resume on the SAME cartridge picks up from the last good checkpoint and finishes to EOM.
+ var resumer = new TapeCalibrator(drive) { Options = FastOptions() };
+ ITapeCalibration? resumed = resumer.Resume();
+
+ Assert.NotNull(resumed);
+ Assert.InRange(resumed!.CapacityActual, (long)(Capacity * 0.98), Capacity);
+ Assert.NotNull(resumed.EarlyWarning);
+ Assert.True(resumed.EwToEomDistance > 0, "EW→EOM distance should be positive");
+ Assert.Equal(drive.DriveProfileKey, resumed.ProfileKey);
+ AssertCurveWellFormed(resumed);
+ }
+
+ [Fact]
+ public void Resume_ProducesEquivalentCalibration_ToAnUninterruptedRun()
+ {
+ // Baseline: a clean, uninterrupted run.
+ var (driveA, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ ITapeCalibration? clean = new TapeCalibrator(driveA) { Options = FastOptions() }.Run();
+ Assert.NotNull(clean);
+
+ // Interrupted-then-resumed run on an equivalent cartridge.
+ var (driveB, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var run = new TapeCalibrator(driveB) { Options = FastOptions() };
+ Assert.Null(run.Run(new AbortAfterBytes(run, Capacity / 2)));
+ ITapeCalibration? resumed = new TapeCalibrator(driveB) { Options = FastOptions() }.Resume();
+ Assert.NotNull(resumed);
+
+ // The deterministic emulation ⇒ capacity and EW landmark land within a tight band of the
+ // clean run (resume re-measures the region after the last checkpoint, so it must agree).
+ Assert.InRange(resumed!.CapacityActual,
+ (long)(clean!.CapacityActual * 0.99), (long)(clean.CapacityActual * 1.01) + 1L);
+ Assert.InRange(resumed.EwToEomDistance,
+ (long)(clean.EwToEomDistance * 0.90), (long)(clean.EwToEomDistance * 1.10) + 1L);
+ }
+
+ [Fact]
+ public void Resume_IsItselfResumable_ConvergesAfterRepeatedFailures()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ // 1) Fresh run, interrupted early (~35%).
+ var r0 = new TapeCalibrator(drive) { Options = FastOptions() };
+ Assert.Null(r0.Run(new AbortAfterBytes(r0, (long)(Capacity * 0.35))));
+
+ // 2) First resume, interrupted again a bit later (~65%). This is the critical case: a resume
+ // must itself remain resumable — its rewritten boundary checkpoint stands as the new anchor.
+ var r1 = new TapeCalibrator(drive) { Options = FastOptions() };
+ Assert.Null(r1.Resume(new AbortAfterBytes(r1, (long)(Capacity * 0.65))));
+
+ // 3) Second resume runs to completion.
+ var r2 = new TapeCalibrator(drive) { Options = FastOptions() };
+ ITapeCalibration? done = r2.Resume();
+
+ Assert.NotNull(done);
+ Assert.InRange(done!.CapacityActual, (long)(Capacity * 0.98), Capacity);
+ Assert.NotNull(done.EarlyWarning);
+ AssertCurveWellFormed(done);
+ }
+
+ [Fact]
+ public void Resume_OnBlankCartridge_ReturnsNull()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ // No run has been performed: there is no header on the medium, so nothing to resume.
+ ITapeCalibration? resumed = new TapeCalibrator(drive) { Options = FastOptions() }.Resume();
+ Assert.Null(resumed);
+ }
+
+ [Fact]
+ public void Resume_RestoresPriorReserveAndCalibrations()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ // Interrupt a fresh run first so there is something to resume.
+ var run = new TapeCalibrator(drive) { Options = FastOptions() };
+ Assert.Null(run.Run(new AbortAfterBytes(run, Capacity / 2)));
+
+ // A pre-existing reserve + matching calibration that the resume must neither taint nor discard.
+ var preloaded = TapeCalibration.Apriori(drive.DriveProfileKey, Capacity);
+ Assert.True(drive.AddCalibration(preloaded));
+ const long reserve = 2L * 1024 * 1024;
+ Assert.True(drive.SetEarlyWarning(reserve));
+
+ Assert.NotNull(new TapeCalibrator(drive) { Options = FastOptions() }.Resume());
+
+ Assert.Equal(reserve, drive.EarlyWarning);
+ Assert.Contains(preloaded, drive.Calibrations);
+ }
+
+ #endregion
+
+ #region *** Recalibrate (end-to-end) ***
+
+ [Fact]
+ public void Recalibrate_AfterCompleteRun_ReassessesTail_WithSmallDelta()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ // A full run to completion leaves the resumable trail (header + body checkpoints) on tape.
+ var run = new TapeCalibrator(drive) { Options = FastOptions() };
+ ITapeCalibration? original = run.Run();
+ Assert.NotNull(original);
+
+ // Recalibrate re-measures only the tail from the last body checkpoint to the new EOM.
+ var recal = new TapeCalibrator(drive) { Options = FastOptions() };
+ (ITapeCalibration? reassessed, TapeRecalibrationDelta delta) = recal.Recalibrate(original!);
+
+ Assert.NotNull(reassessed);
+ AssertCurveWellFormed(reassessed!);
+
+ // Same virtual drive + deterministic profile ⇒ the key figures barely move.
+ Assert.InRange(reassessed!.CapacityActual,
+ (long)(original!.CapacityActual * 0.99), (long)(original.CapacityActual * 1.01) + 1L);
+ Assert.True(Math.Abs(delta.CapacityShiftFraction) < 0.02,
+ $"Capacity shift {delta.CapacityShiftFraction:P1} unexpectedly large for a stable drive");
+
+ // This would measure EW drift in relative terms:
+ //Assert.True(Math.Abs(delta.EwShiftFraction) < 0.05,
+ // $"EW shift {delta.EwShiftFraction:P1} unexpectedly large for a stable drive");
+ // But EwToEomDistance is inherently quantized to the tail chunk size, and a resume re-measures the
+ // tail from a byte position shifted by the rewritten checkpoint record block — so a difference of
+ // a couple of tail chunks is EXPECTED quantization, not drift. Assert an ABSOLUTE tolerance in
+ // those terms; a percentage bound is meaningless for a small, block-quantized quantity.
+ long tailChunk = FastOptions().ResolveFor(drive).TailChunkSize;
+ Assert.True(Math.Abs(delta.NewEwToEomDistance - delta.OldEwToEomDistance) <= 3 * tailChunk,
+ $"EW moved {delta.NewEwToEomDistance - delta.OldEwToEomDistance} B (> 3 tail chunks) — beyond quantization");
+
+ // The delta reports the raw before/after values verdict-free (caller decides what they mean).
+ Assert.Equal(original.CapacityActual, delta.OldCapacityActual);
+ Assert.Equal(reassessed.CapacityActual, delta.NewCapacityActual);
+ Assert.Equal(original.EwToEomDistance, delta.OldEwToEomDistance);
+ Assert.Equal(reassessed.EwToEomDistance, delta.NewEwToEomDistance);
+ }
+
+ [Fact]
+ public void Recalibrate_OnBlankCartridge_ReturnsNullReassessed()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ // No trail on the medium ⇒ nothing to re-measure from.
+ var existing = TapeCalibration.Apriori(drive.DriveProfileKey, Capacity);
+ (ITapeCalibration? reassessed, _) = new TapeCalibrator(drive) { Options = FastOptions() }
+ .Recalibrate(existing);
+
+ Assert.Null(reassessed);
+ }
+
+ #endregion
+}
diff --git a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
index 6062e8e..efced57 100644
--- a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
+++ b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
@@ -77,7 +77,7 @@ public async Task ExecuteCalibrateAsync_HonorsAbortRequest()
EjectWhenDone: false,
Options: new TapeCalibrationOptions
{
- SampleCount = 16,
+ SampleCount = 40,
//MinSampleInterval = 1L * MB,
//ChunkBytesTarget = 1L * MB,
})
@@ -92,7 +92,7 @@ public async Task ExecuteCalibrateAsync_HonorsAbortRequest()
Assert.True(result.WasAborted);
Assert.False(result.Success);
Assert.Null(result.Calibration);
- Assert.True(host.ContainsMessage("Calibration abort requested"));
+ Assert.True(host.ContainsMessage("Calibration abort"));
}
}
diff --git a/TapeLibNET.Tests/VirtualDriveBasicTests.cs b/TapeLibNET.Tests/VirtualDriveBasicTests.cs
index be9e7d1..05fde68 100644
--- a/TapeLibNET.Tests/VirtualDriveBasicTests.cs
+++ b/TapeLibNET.Tests/VirtualDriveBasicTests.cs
@@ -19,7 +19,7 @@ public class VirtualDriveBasicTests
///
/// Provides the three real-world drive profiles as [Theory] data.
///
-#pragma warning disable CA1825 // Avoid zero-length array allocations
+//#pragma warning disable CA1825 // Avoid zero-length array allocations
public static TheoryData AllProfiles =>
[
DriveProfile.Setmarks,
@@ -27,7 +27,7 @@ public class VirtualDriveBasicTests
DriveProfile.SeqFilemarks,
DriveProfile.FilemarksOnly,
];
-#pragma warning restore CA1825 // Avoid zero-length array allocations
+//#pragma warning restore CA1825 // Avoid zero-length array allocations
///
/// Profiles that can save/restore TOC on an empty tape (no prior content).
@@ -35,13 +35,13 @@ public class VirtualDriveBasicTests
/// requires existing TOC markers on tape, which only exist after content has been written.
/// This matches real SDLT hardware behavior.
///
-#pragma warning disable CA1825 // Avoid zero-length array allocations
+//#pragma warning disable CA1825 // Avoid zero-length array allocations
public static TheoryData ProfilesWithTOCOnEmptyTape =>
[
DriveProfile.Setmarks,
DriveProfile.Partitions,
];
-#pragma warning restore CA1825 // Avoid zero-length array allocations
+//#pragma warning restore CA1825 // Avoid zero-length array allocations
#endregion
@@ -558,4 +558,131 @@ public void Fixture_HasThrottlingDisabled(DriveProfile profile)
}
#endregion
+
+ #region *** Overwrite Near EOM (Resume Regression) ***
+
+ // These tests pin the two capacity-check bugs fixed for resumable calibration, where writing must
+ // RESUME on an already-full tape after seeking BACKWARD in front of the last filemark. Both
+ // VirtualTapeMedia.WriteBlocks and WriteMark used to test TrueRemaining BEFORE reclaiming the
+ // trailing space via truncation, so an overwrite-in-front-of-tail wrongly failed with END_OF_MEDIA
+ // even though it was about to free the whole tail. The fix truncates FIRST, then checks capacity —
+ // an overwrite reclaims space (as real tape sets a new EOD) while a genuine EOD still refuses.
+
+ // Single-partition, large-block profile — mirrors the LTO single-partition calibration scenario.
+ private const DriveProfile ResumeProfile = DriveProfile.FilemarksOnly;
+
+ ///
+ /// Builds a fixture whose content capacity is an EXACT multiple of the (max) block size, so the tape
+ /// can be filled precisely to hard EOM (TrueRemaining == 0) — the state that exposed both bugs.
+ /// Asserts the content partition starts empty so the exact-fill accounting below holds.
+ ///
+ private static (VirtualTapeFixture Fixture, int BlockSize, int BlockCount) CreateExactlyFillableDrive(
+ int blockCount = 8)
+ {
+ var caps = VirtualTapeFixture.ProfileToCapabilities(ResumeProfile);
+ int blockSize = (int)caps.MaxBlockSize;
+ long capacity = (long)blockCount * blockSize; // exact multiple ⇒ the tape fills precisely
+
+ var fixture = new VirtualTapeFixture(ResumeProfile, contentCapacity: capacity);
+ Assert.True(fixture.Drive.SetBlockSize((uint)blockSize));
+
+ Assert.True(fixture.Drive.MoveToPartition(MediaPartition.Content));
+ Assert.True(fixture.Drive.Rewind());
+ // Verify the drive is created empty, with whole capacity available:
+ Assert.Equal(capacity, fixture.Drive.GetReportedContentRemaining());
+
+ return (fixture, blockSize, blockCount);
+ }
+
+ [Fact]
+ public void WriteBlocks_OverwriteAfterBackwardSeek_OnFullTape_Succeeds()
+ {
+ var (fixture, blockSize, blockCount) = CreateExactlyFillableDrive();
+ using (fixture)
+ {
+ var drive = fixture.Drive;
+ var block = new byte[blockSize];
+
+ // Fill the content partition EXACTLY to hard EOM with full data blocks.
+ for (int i = 0; i < blockCount; i++)
+ {
+ Array.Fill(block, (byte)(0x40 + i));
+ int n = drive.WriteDirect(block, 0, blockSize, out _, out _, out bool eom);
+ Assert.Equal(blockSize, n);
+ Assert.False(eom, "should not hit EOM until the tape is exactly full");
+ }
+
+ // Exactly full: a further full block does not fit (genuine EOD is still refused — proving the
+ // fix did not over-relax the capacity guard).
+ Assert.Equal(0L, drive.GetReportedContentRemaining());
+ int overflow = drive.WriteDirect(block, 0, blockSize, out _, out _, out bool eomFull);
+ Assert.Equal(0, overflow);
+ Assert.True(eomFull, "a full block past hard EOM must be refused");
+
+ // Seek BACKWARD into the middle and overwrite: truncation reclaims the tail, so the write
+ // must now SUCCEED — the WriteBlocks half of the fix.
+ long midBlock = blockCount / 2;
+ Assert.True(drive.MoveToBlock(midBlock));
+
+ Array.Fill(block, (byte)0x99);
+ int rewritten = drive.WriteDirect(block, 0, blockSize, out _, out _, out bool eomMid);
+ Assert.Equal(blockSize, rewritten);
+ Assert.False(eomMid, "overwrite after a backward seek must not report EOM — the tail was reclaimed");
+
+ // The overwrite set a new EOD at midBlock+1, freeing the blocks that had followed.
+ Assert.Equal((long)(blockCount - (midBlock + 1)) * blockSize, drive.GetReportedContentRemaining());
+
+ // And the freshly written block reads back correctly.
+ Assert.True(drive.MoveToBlock(midBlock));
+ var readBack = new byte[blockSize];
+ int read = drive.ReadDirect(readBack, 0, blockSize);
+ Assert.Equal(blockSize, read);
+ Assert.Equal(block, readBack);
+ }
+ }
+
+ [Fact]
+ public void WriteFilemark_OverwriteAfterBackwardSeek_OnFullTape_Succeeds()
+ {
+ var (fixture, blockSize, blockCount) = CreateExactlyFillableDrive();
+ using (fixture)
+ {
+ var drive = fixture.Drive;
+ var block = new byte[blockSize];
+
+ // Lay down [block] FM [block]*(blockCount-1): one filemark after the first block, then fill
+ // the rest with data so the tape ends EXACTLY full (marks consume no data capacity).
+ Array.Fill(block, (byte)0x11);
+ Assert.Equal(blockSize, drive.WriteDirect(block, 0, blockSize));
+ Assert.True(drive.WriteFilemark(1));
+
+ for (int i = 1; i < blockCount; i++)
+ {
+ Array.Fill(block, (byte)(0x20 + i));
+ Assert.Equal(blockSize, drive.WriteDirect(block, 0, blockSize, out _, out _, out bool eom));
+ Assert.False(eom);
+ }
+
+ Assert.Equal(0L, drive.GetReportedContentRemaining()); // exactly full
+
+ // A filemark at genuine EOD must still be refused (guard not over-relaxed).
+ Assert.True(drive.FastforwardToEnd(MediaPartition.Content));
+ Assert.False(drive.WriteFilemark(1), "a filemark at hard EOM must be refused");
+
+ // Now resume the way the calibrator does: seek back in front of the last filemark and rewrite
+ // it. Truncation reclaims the trailing data, so the filemark write must SUCCEED — the
+ // WriteMark half of the fix, the exact failure seen while resuming a calibration.
+ Assert.True(drive.FastforwardToEnd(MediaPartition.Content));
+ Assert.True(drive.MoveToNextFilemark(-1));
+ Assert.True(drive.WriteFilemark(1),
+ $"Filemark overwrite after a backward seek on a full tape must succeed: {drive.LastErrorMessage}");
+
+ // The reclaimed space is available again, and writing continues past the new mark.
+ Assert.True(drive.GetReportedContentRemaining() > 0);
+ Array.Fill(block, (byte)0x77);
+ Assert.Equal(blockSize, drive.WriteDirect(block, 0, blockSize));
+ }
+ }
+
+ #endregion
}
diff --git a/TapeLibNET/TapeCalibrationCheckpoint.cs b/TapeLibNET/TapeCalibrationCheckpoint.cs
new file mode 100644
index 0000000..3c9d1a1
--- /dev/null
+++ b/TapeLibNET/TapeCalibrationCheckpoint.cs
@@ -0,0 +1,280 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Hashing;
+
+namespace TapeLibNET;
+
+// =============================================================================
+// RESUMABLE CALIBRATION — on-tape checkpoint records
+//
+// A calibration run writes a self-describing trail so a run interrupted by a
+// transport fault (bus reset, power loss, app crash) can be RESUMED from the
+// last good checkpoint instead of restarting from BOM. The cartridge is the
+// single source of truth — no host-side sidecar — so a retained calibration
+// cartridge carries practically the whole run state.
+//
+// SINGLE-FILEMARK layout ('FM' = filemark). Each FM immediately PRECEDES a
+// checkpoint block, so the resume walk always lands at a checkpoint-block
+// start — never inside payload gibberish, even if a checkpoint write was torn:
+//
+// BOM
+// │ ┌─ header ─┐┌ payload ┐ ┌ checkpt 0 ┐┌ payload ┐ ┌ checkpt 1 ┐
+// ├▶│ block ││ blocks │─FM─▶│ block ││ blocks │─FM─▶│ block │─FM─▶ …
+// │ └──────────┘└─────────┘ └───────────┘└─────────┘ └───────────┘
+// │ RunId,plan, cumulative cumulative
+// │ capacity samples+EW+bytes samples+EW+bytes
+// │
+// │ … ┌ checkpt k ┐┌ partial payload ┐
+// … ─▶│ block ││ (write failed) │ ◀── EOD (no trailing FM)
+// └───────────┘└──────────────────┘
+// ▲
+// Resume READ: FastforwardToEnd ─▶ MoveToNextFilemark(-n) ─▶ MoveToNextFilemark(+1)
+// ─▶ ReadDirect one block ─▶ Unpack+CRC
+// valid & RunId match? yes → use it
+// no → n++ and retry (torn/foreign)
+// BOP → no resumable run (header-only / blank)
+// Resume WRITE: FastforwardToEnd ─▶ MoveToNextFilemark(-n) (lands BOP-side of the FM
+// before the good checkpoint) ─▶ rewrite FM + checkpoint + payload.
+//
+// Each record occupies ONE calibration block (the run's normal block size,
+// e.g. 1 MB on LTO). The framed record sits at the FRONT; the remaining block
+// bytes are random padding (compression is off, so content is immaterial to
+// position — random simply keeps the block consistent with the payload and
+// avoids a compressible run should a profile ever run with compression on).
+// The FULL block is counted in bytesWritten, so the reported→actual mapping
+// stays honest and even reflects real set-delimited overhead.
+//
+// NOTE: checkpoints are laid down in the BODY only (never the tail), so the
+// last checkpoint is always PRE-tail — exactly the restart point Resume needs
+// and the re-measure point Recalibrate needs.
+// =============================================================================
+
+///
+/// Written once as the header block at BOM. Self-identifies the run and cartridge so Resume can
+/// verify "same run" (internal consistency) before trusting any checkpoint, and so a
+/// returned cartridge is inspectable ("what run / drive / when does this hold?"). Profile MATCHING against
+/// the current drive is deliberately NOT done here — that is the caller's / service layer's responsibility.
+///
+public sealed record TapeCalibrationRunHeader(
+ Guid RunId,
+ string ProfileKey,
+ long CapacityReportedAtBom,
+ uint BlockSize,
+ DateTime StartedUtc,
+ TapeCalibrationPlan Plan) : ITapeSerializable
+{
+ public void SerializeTo(TapeSerializer s)
+ {
+ s.SerializeSignature();
+
+ s.Serialize(RunId.ToByteArray()); // 16 raw bytes (fixed length)
+ s.Serialize(ProfileKey); // length-prefixed UTF-8
+ s.Serialize(CapacityReportedAtBom);
+ s.Serialize(BlockSize);
+ s.Serialize(StartedUtc); // ticks
+
+ // Plan — enough to resume with an IDENTICAL cadence/chunking, without re-resolving.
+ s.Serialize(Plan.SampleCount);
+ s.Serialize(Plan.BodySampleCount);
+ s.Serialize(Plan.TailSampleCount);
+ s.Serialize(Plan.BlockSize);
+ s.Serialize(Plan.BlocksPerChunk);
+ s.Serialize(Plan.ChunkSize);
+ s.Serialize(Plan.TailBlocksPerChunk);
+ s.Serialize(Plan.TailChunkSize);
+ s.Serialize(Plan.TailCapacityFraction);
+ s.Serialize(Plan.NumCheckpoints);
+ }
+
+ public static ITapeSerializable? ConstructFrom(TapeDeserializer d)
+ {
+ if (!d.ValidateSignature())
+ return null; // wrong signature/version → not our record
+
+ var runId = new Guid(d.DeserializeBytes(16) ?? throw new FormatException("RunId"));
+ string profileKey = d.DeserializeString();
+ long capacity = d.DeserializeInt64();
+ uint blockSize = d.DeserializeUInt32();
+ DateTime started = d.DeserializeDateTime();
+
+ var plan = new TapeCalibrationPlan(
+ d.DeserializeInt32(), // SampleCount
+ d.DeserializeInt32(), // BodySampleCount
+ d.DeserializeInt32(), // TailSampleCount
+ d.DeserializeUInt32(), // BlockSize
+ d.DeserializeInt32(), // BlocksPerChunk
+ d.DeserializeInt32(), // ChunkSize
+ d.DeserializeInt32(), // TailBlocksPerChunk
+ d.DeserializeInt32(), // TailChunkSize
+ d.DeserializeDouble(), // TailCapacityFraction
+ d.DeserializeInt32()); // NumCheckpoints
+
+ return new TapeCalibrationRunHeader(runId, profileKey, capacity, blockSize, started, plan);
+ }
+}
+
+///
+/// Written at each body checkpoint. CUMULATIVE and self-contained: a single valid read fully restores
+/// run state (bytes written so far, all samples, the EW landmark if seen). Small — ~16 bytes per sample,
+/// so ≤ ~16 KB even near the end — comfortably inside one calibration block.
+///
+/// is the byte count as of the FM that PRECEDES this checkpoint block (i.e.
+/// before the "FM + checkpoint block" pair is written). On resume the tape is repositioned BOP-side of
+/// that FM and the pair is rewritten from the restored state, reproducing identical byte accounting.
+///
+///
+public sealed record TapeCalibrationCheckpoint(
+ Guid RunId,
+ int Index,
+ long BytesWritten,
+ (long ActualWritten, long ReportedRemaining)? EarlyWarning,
+ IReadOnlyList<(long ActualWritten, long ReportedRemaining)> Samples) : ITapeSerializable
+{
+ public void SerializeTo(TapeSerializer s)
+ {
+ s.SerializeSignature();
+
+ s.Serialize(RunId.ToByteArray());
+ s.Serialize(Index);
+ s.Serialize(BytesWritten);
+
+ s.Serialize(EarlyWarning.HasValue);
+ if (EarlyWarning is { } ew)
+ {
+ s.Serialize(ew.ActualWritten);
+ s.Serialize(ew.ReportedRemaining);
+ }
+
+ s.Serialize(Samples.Count);
+ foreach (var (aw, rr) in Samples)
+ {
+ s.Serialize(aw);
+ s.Serialize(rr);
+ }
+ }
+
+ public static ITapeSerializable? ConstructFrom(TapeDeserializer d)
+ {
+ if (!d.ValidateSignature())
+ return null;
+
+ var runId = new Guid(d.DeserializeBytes(16) ?? throw new FormatException("RunId"));
+ int index = d.DeserializeInt32();
+ long bytesWritten = d.DeserializeInt64();
+
+ (long, long)? ew = null;
+ if (d.DeserializeBoolean())
+ ew = (d.DeserializeInt64(), d.DeserializeInt64());
+
+ int count = d.DeserializeInt32();
+ var samples = new List<(long ActualWritten, long ReportedRemaining)>(Math.Max(0, count));
+ for (int i = 0; i < count; i++)
+ samples.Add((d.DeserializeInt64(), d.DeserializeInt64()));
+
+ return new TapeCalibrationCheckpoint(runId, index, bytesWritten, ew, samples);
+ }
+}
+
+///
+/// Frames an calibration record for on-tape storage with a CRC-32 guard,
+/// so a torn tail record is DETECTED (and the resume walk steps back) rather than silently deserialized
+/// into garbage. Reuses the library's / plumbing.
+///
+/// Wire framing: [int32 payloadLen][payload][4-byte crc], where payload is the record's own
+/// output (signature + fields) and crc is CRC-32 over
+/// that payload — kept OUTSIDE the hashed span. The whole frame is copied into the front of a full block;
+/// the block's remaining bytes are caller-supplied random padding (ignored on read-back).
+///
+///
+public static class TapeCalibrationRecord
+{
+ /// Serializes and returns the framed [len][payload][crc] bytes.
+ public static byte[] Pack(ITapeSerializable record)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+
+ // Serialize the payload while hashing it — reuse HashingStream over a growable MemoryStream.
+ using var payloadMs = new MemoryStream();
+ var crc = new Crc32();
+ using (var hashing = new HashingStream(payloadMs, crc, ownInner: false))
+ {
+ var ser = new TapeSerializer(hashing);
+ record.SerializeTo(ser);
+ }
+
+ byte[] payload = payloadMs.ToArray();
+ byte[] crcBytes = crc.GetCurrentHash(); // 4 bytes
+
+ using var frameMs = new MemoryStream(payload.Length + 8);
+ var frameSer = new TapeSerializer(frameMs);
+ frameSer.Serialize(payload.Length); // int32 length prefix
+ frameSer.Serialize(payload); // raw payload (already hashed)
+ frameSer.Serialize(crcBytes); // raw 4-byte CRC trailer (outside the hash)
+
+ return frameMs.ToArray();
+ }
+
+ ///
+ /// Parses a framed record out of a full block read back from tape and verifies its CRC. Returns the
+ /// reconstructed record, or when the block is not one of our records, is torn,
+ /// or fails the CRC — the exact signals the resume walk treats as "step back to the previous checkpoint".
+ ///
+ public static T? Unpack(byte[] block, int length) where T : class, ITapeSerializable
+ {
+ ArgumentNullException.ThrowIfNull(block);
+
+ try
+ {
+ using var ms = new MemoryStream(block, 0, Math.Min(length, block.Length), writable: false);
+ var d = new TapeDeserializer(ms);
+
+ int payloadLen = d.DeserializeInt32();
+ if (payloadLen < 0 || payloadLen > block.Length - 8)
+ return null; // implausible length ⇒ not a valid frame
+
+ byte[]? payload = d.DeserializeBytes(payloadLen);
+ byte[]? crcStored = d.DeserializeBytes(4);
+ if (payload is null || crcStored is null)
+ return null;
+
+ var crc = new Crc32();
+ crc.Append(payload);
+ if (!crc.GetCurrentHash().AsSpan().SequenceEqual(crcStored))
+ return null; // CRC mismatch ⇒ torn / corrupt
+
+ using var pms = new MemoryStream(payload, writable: false);
+ var pd = new TapeDeserializer(pms);
+ return T.ConstructFrom(pd) as T; // ConstructFrom re-checks signature/version
+ }
+ catch (Exception)
+ {
+ // Any framing/format error ⇒ treat as an invalid record; the caller walks back.
+ return null;
+ }
+ }
+}
+
+///
+/// Raw, verdict-free deltas produced by : how the freshly
+/// re-measured tail moved the key figures versus the existing calibration. This is DATA, not advice —
+/// the caller (service / UI) decides whether the shift is small enough to keep the reassessed calibration
+/// or large enough to warrant a full re-run. The convenience fractions are signed (new − old).
+///
+public readonly record struct TapeRecalibrationDelta(
+ long OldEwToEomDistance, long NewEwToEomDistance,
+ long OldCapacityActual, long NewCapacityActual,
+ long OldPhantomFreeAtEom, long NewPhantomFreeAtEom)
+{
+ /// Signed relative shift of the EW→EOM distance (the most critical figure), or 0 if old was 0.
+ public double EwShiftFraction
+ => OldEwToEomDistance > 0 ? (double)(NewEwToEomDistance - OldEwToEomDistance) / OldEwToEomDistance : 0.0;
+
+ /// Signed relative shift of the measured actual capacity, or 0 if old was 0.
+ public double CapacityShiftFraction
+ => OldCapacityActual > 0 ? (double)(NewCapacityActual - OldCapacityActual) / OldCapacityActual : 0.0;
+
+ /// Signed relative shift of the phantom-free-at-EOM figure, or 0 if old was 0.
+ public double PhantomShiftFraction
+ => OldPhantomFreeAtEom > 0 ? (double)(NewPhantomFreeAtEom - OldPhantomFreeAtEom) / OldPhantomFreeAtEom : 0.0;
+}
diff --git a/TapeLibNET/TapeCalibrationOptions.cs b/TapeLibNET/TapeCalibrationOptions.cs
index 875c374..25ee520 100644
--- a/TapeLibNET/TapeCalibrationOptions.cs
+++ b/TapeLibNET/TapeCalibrationOptions.cs
@@ -22,7 +22,7 @@ public readonly record struct TapeCalibrationOptions
/// Fraction of reserved for the fine-grained TAIL phase (the EW → EOM
/// region). Real LTO runs showed the default 100/1,000 uniform points far too coarse for that
/// last stretch — LTO-3 in particular collapses its reported figure right at EW — so we spend a
- /// dedicated slice of the budget there, at a proportionally finer chunk. Default 0.20 (20%).
+ /// dedicated slice of the budget there, at a proportionally finer chunk. Default 0.40 (40%).
///
public double TailSampleFraction { get; init; }
@@ -41,6 +41,16 @@ public readonly record struct TapeCalibrationOptions
///
public bool CaptureLtoRemaining { get; init; }
+ ///
+ /// Target number of resumable CHECKPOINTS to lay down across the body of the medium (the tail is
+ /// intentionally NOT checkpointed — a failure there has already written ~95%, so re-doing the small
+ /// tail is cheap). Each checkpoint is one filemark-delimited record block holding cumulative run
+ /// state, so a crashed run can be resumed from the last good one. Default 128 ⇒ ~1% granularity —
+ /// fine, yet few enough that the per-filemark flush cost stays negligible. For small VIRTUAL test
+ /// media set this low (e.g. 8) so the mechanism exercises without an enormous run.
+ ///
+ public int NumCheckpoints { get; init; }
+
/// Default value for .
public const int DefaultBlocksPerChunk = 8;
@@ -50,14 +60,17 @@ public readonly record struct TapeCalibrationOptions
/// Default value for — the tail is the last 5% of capacity (or EW, whichever first).
public const double DefaultTailCapacityFraction = 0.05;
+ /// Default value for — 128 body checkpoints (~1% granularity).
+ public const int DefaultNumCheckpoints = 128;
+
public TapeCalibrationOptions()
{
SampleCount = 1_000; // 1,000 proved good resolution for LTO drives
BlocksPerChunk = DefaultBlocksPerChunk;
TailSampleFraction = DefaultTailSampleFraction; // reserve 40% of the budget for the EW→EOM tail
TailCapacityFraction = DefaultTailCapacityFraction; // tail = last 5% of capacity (or EW, whichever first)
-
- CaptureLtoRemaining = false; // proven redundant across LTO-3/4/6 — off by default
+ CaptureLtoRemaining = false; // proven redundant across LTO-3/4/6 — off by default
+ NumCheckpoints = DefaultNumCheckpoints; // 128 resumable body checkpoints (~1% granularity)
}
/// Turn caller intent into a concrete, always-valid plan for this drive.
@@ -71,6 +84,8 @@ public TapeCalibrationPlan ResolveFor(TapeDrive drive)
double tailSampleFraction = Math.Clamp(TailSampleFraction, 0.0, 0.9);
double tailCapacityFraction = Math.Clamp(TailCapacityFraction, 0.0, 0.9);
+ int numCheckpoints = Math.Max(1, NumCheckpoints);
+
// Split the sample budget: a reserved slice for the fine tail, the rest for the body.
int tailSampleCount = Math.Max(1, (int)(sampleCount * tailSampleFraction));
int bodySampleCount = Math.Max(1, sampleCount - tailSampleCount);
@@ -108,7 +123,7 @@ public TapeCalibrationPlan ResolveFor(TapeDrive drive)
return TapeCalibrationPlan.Create(
sampleCount, bodySampleCount, tailSampleCount,
- blockSize, blocksPerChunk, tailBlocksPerChunk, tailCapacityFraction);
+ blockSize, blocksPerChunk, tailBlocksPerChunk, tailCapacityFraction, numCheckpoints);
}
}
@@ -118,6 +133,7 @@ public TapeCalibrationPlan ResolveFor(TapeDrive drive)
/// valid (no divide-by-zero path). The run has two phases: a coarse BODY (chunk ,
/// interval ) and a fine TAIL (chunk ,
/// interval ) that begins at EW or the last .
+/// Resumable checkpoints are laid down across the body at .
///
public readonly record struct TapeCalibrationPlan(
int SampleCount,
@@ -128,12 +144,14 @@ public readonly record struct TapeCalibrationPlan(
int ChunkSize,
int TailBlocksPerChunk,
int TailChunkSize,
- double TailCapacityFraction)
+ double TailCapacityFraction,
+ int NumCheckpoints)
{
/// Build a plan, deriving the two chunk sizes and clamping the counts to ≥ 1.
internal static TapeCalibrationPlan Create(
int sampleCount, int bodySampleCount, int tailSampleCount,
- uint blockSize, int blocksPerChunk, int tailBlocksPerChunk, double tailCapacityFraction)
+ uint blockSize, int blocksPerChunk, int tailBlocksPerChunk, double tailCapacityFraction,
+ int numCheckpoints)
{
int chunkSize = checked((int)(blocksPerChunk * (long)blockSize));
int tailChunkSize = checked((int)(tailBlocksPerChunk * (long)blockSize));
@@ -145,7 +163,8 @@ internal static TapeCalibrationPlan Create(
blockSize,
Math.Max(1, blocksPerChunk), chunkSize,
Math.Max(1, tailBlocksPerChunk), tailChunkSize,
- tailCapacityFraction);
+ tailCapacityFraction,
+ Math.Max(1, numCheckpoints));
}
///
@@ -174,4 +193,10 @@ public long BodySampleInterval(long capacity)
/// points across the last of the medium.
public long TailSampleInterval(long capacity)
=> Math.Max(TailChunkSize, (long)(capacity * TailCapacityFraction) / Math.Max(1, TailSampleCount));
+
+ /// Byte spacing between resumable checkpoints in the BODY: ~ across
+ /// , but never denser than one body chunk (so tiny media don't checkpoint
+ /// every write). The tail is not checkpointed.
+ public long CheckpointInterval(long capacity)
+ => Math.Max(ChunkSize, capacity / Math.Max(1, NumCheckpoints));
}
diff --git a/TapeLibNET/TapeCalibrator.cs b/TapeLibNET/TapeCalibrator.cs
index 9c2579f..6b6cc2f 100644
--- a/TapeLibNET/TapeCalibrator.cs
+++ b/TapeLibNET/TapeCalibrator.cs
@@ -1,5 +1,4 @@
using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using Windows.Win32.Foundation;
@@ -36,16 +35,23 @@ public readonly record struct TapeCalibrationProgress(
/// Sampling is TWO-PHASE (see ): a coarse BODY across most of the
/// medium, then a fine TAIL over the EW → EOM region (entered at physical EW or the last few percent
/// of capacity, whichever comes first). Real LTO runs proved a uniform cadence far too coarse in that
-/// tail — LTO-4 keeps ~31 GB of phantom-free runway past EW, while LTO-3 collapses its reported
-/// figure to 0 the instant EW fires — so the tail earns a dedicated, proportionally finer chunk.
+/// tail — LTO-4/6 keep tens/hundreds of GB of phantom-free runway past EW, while LTO-3 collapses its
+/// reported figure to 0 the instant EW fires — so the tail earns a dedicated, proportionally finer chunk.
///
///
-/// Conceptually create-use-discard: new TapeCalibrator(drive).Run(). Backend-agnostic — it
-/// drives only the public surface, so it works identically for the Win32,
-/// remote, and virtual backends. The one EXPERIMENTAL exception is the optional native (LTO) remaining
-/// probe, which reaches into a Win32 backend directly to cross-check the driver figure. Cancellation is
-/// cooperative via (poll/flip from the caller's async wrapper),
-/// mirroring TapeFileAgent.
+/// RESUMABLE: the run lays down a self-describing on-tape trail (a header block at BOM plus body
+/// checkpoints, filemark-delimited — see ). A run interrupted by a
+/// transport fault can be continued with from the last good checkpoint, and a
+/// COMPLETE calibration cartridge can be re-measured cheaply after a firmware update / drive swap with
+/// . The cartridge is the single source of truth — no host sidecar.
+///
+///
+/// Conceptually create-use-discard: new TapeCalibrator(drive).Run(). Backend-agnostic — it drives
+/// only the public surface (including filemark write/space), so it works
+/// identically for the Win32, remote, and virtual backends. The one EXPERIMENTAL exception is the optional
+/// native (LTO) remaining probe, which reaches into a Win32 backend directly. Cancellation is cooperative
+/// via . This class does NOT judge drive-profile matching — that is the
+/// caller's / service layer's responsibility.
///
///
public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder(drive)
@@ -72,22 +78,32 @@ public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder
+ /// Mutable state carried through the shared write loop, so a fresh and a
+ /// / continuation drive the identical machinery —
+ /// the only difference is how the state is seeded (empty at BOM vs. restored from a checkpoint).
+ ///
+ private sealed class RunState
{
- if (IsAbortRequested)
- {
- SetError(WIN32_ERROR.ERROR_CANCELLED);
- m_logger.LogWarning("{Prefix}: Calibration aborted by caller", LogPrefix);
- return true;
- }
- return false;
+ public Guid RunId;
+ public long BytesWritten;
+ public int CheckpointIndex;
+ // public bool InTail; -- not needed, set dynamically in RunLoop(): inTail = state.BytesWritten >= tailStartBytes;
+ public (long ActualWritten, long ReportedRemaining)? EwPoint;
+ public readonly List<(long ActualWritten, long ReportedRemaining)> Samples = [];
+ public readonly List<(long ActualWritten, long LtoRemaining)> LtoSamples = [];
}
+ #endregion
+
+ #region *** Public API ***
+
///
- /// Executes the calibration. DESTRUCTIVE: overwrites the medium from BOT of the content partition.
- /// Leaves the tape at (or just past) EOM; the caller typically reformats/reloads afterward.
+ /// Executes a FRESH calibration from BOM. DESTRUCTIVE: overwrites the medium from BOT of the content
+ /// partition. Writes a header block at BOM and body checkpoints as it goes, so an interruption can be
+ /// continued later via . Leaves the tape at (or just past) EOM.
///
/// Optional progress sink (fired on each sample and on EW/EOM).
/// The calibration, or on failure/abort (see ).
@@ -103,143 +119,256 @@ private bool CheckForAbort()
return null;
}
- // Neutralize any active reserve and loaded calibrations for the duration so WriteDirect surfaces
- // the RAW physical early warning the run must measure (not a logical/calibrated remapping). Also
- // enables the backend to report its physical EW (SetEarlyWarning always requests backend EW).
- // All restored in the finally below.
- long savedReserve = Drive.EarlyWarning;
- var savedCalibrations = new List(Drive.Calibrations);
- Drive.RemoveAllCalibrations();
- Drive.SetEarlyWarning(0); // clears reserve AND enables backend physical-EW reporting
- Drive.ResetEarlyWarningRuntime();
-
- // --- Resolve caller intent into a concrete, drive-specific plan ---
- TapeCalibrationPlan plan = Options.ResolveFor(Drive);
-
- // --- First of all, position at BOM of the content partition ---
- // to ensure the new block size applies to the content partition!
- if (!Drive.MoveToPartition(MediaPartition.Content) || !Drive.Rewind())
+ RunGuard guard = new(this);
+ try
{
- SyncErrorFrom(Drive);
- LogErrorAsDebug("Calibration: failed to rewind content partition");
- return null;
+ if (!PrepareDrive(out TapeCalibrationPlan plan, out uint blockSize))
+ return null;
+
+ // Establish the driver-reported remaining at BOM (leaves the tape rewound at BOM).
+ if (!EstablishBomCapacity(out long capacityReportedAtBom))
+ return null;
+
+ // Seed fresh state and lay down the header block as File 0 at BOM.
+ var state = new RunState { RunId = Guid.NewGuid(), BytesWritten = 0, CheckpointIndex = 0 };
+ state.Samples.Add((0L, capacityReportedAtBom));
+
+ var header = new TapeCalibrationRunHeader(
+ state.RunId, Drive.DriveProfileKey, capacityReportedAtBom, blockSize, DateTime.UtcNow, plan);
+
+ using var records = new RecordBlockWriter(this, blockSize);
+ if (!records.Emit(header, ref state.BytesWritten, writeLeadingFilemark: false))
+ {
+ LogErrorAsDebug("Calibration: failed to write run header");
+ return null;
+ }
+
+ m_logger.LogInformation(
+ "{Prefix}: Calibration start — run {RunId}, profile '{Key}', reportedCapacityAtBom {Cap}, " +
+ "blockSize {Bs}, samples {Samples} (body {Body} + tail {Tail}), checkpoints {Chk}",
+ LogPrefix, state.RunId, Drive.DriveProfileKey, capacityReportedAtBom, blockSize,
+ plan.SampleCount, plan.BodySampleCount, plan.TailSampleCount, plan.NumCheckpoints);
+
+ return RunLoop(plan, capacityReportedAtBom, state, records, progress);
}
+ finally
+ {
+ guard.Restore();
+ }
+ }
- // --- Configure the drive for a deterministic byte→position mapping ---
- if (!Drive.SetBlockSize(plan.BlockSize))
+ ///
+ /// Resumes a calibration interrupted by a transport fault, continuing from the last good on-tape
+ /// checkpoint on the CURRENTLY LOADED cartridge. Reads the header, walks back to the last valid
+ /// checkpoint (CRC-verified, same RunId), restores run state, and writes on to EOM.
+ ///
+ /// Returns (error state set) when no resumable run is found on the cartridge —
+ /// e.g. a blank/foreign tape, or a run that failed before the first checkpoint. The caller may then
+ /// fall back to a fresh . Does NOT verify that the cartridge belongs to this drive:
+ /// loading the correct cartridge, and deciding to trust the result, is the caller's responsibility.
+ ///
+ ///
+ public ITapeCalibration? Resume(IProgress? progress = null)
+ {
+ ResetError();
+ IsAbortRequested = false;
+
+ RunGuard guard = new(this);
+ try
{
- SyncErrorFrom(Drive);
- LogErrorAsDebug("Calibration: failed to set block size");
- return null;
+ return ResumeCore(progress);
}
+ finally
+ {
+ guard.Restore();
+ }
+ }
- uint blockSize = Drive.BlockSize; // effective value the drive accepted
- if (blockSize == 0)
+ ///
+ /// FAST post-firmware / post-swap re-measurement. Given an calibration and
+ /// its (retained) calibration cartridge, re-runs only from the last body checkpoint to the new EOM —
+ /// a few percent of the medium instead of a full pass — and rebuilds a reassessed calibration:
+ /// the body curve is REUSED from the trail (its actual-remaining values auto-translate to the freshly
+ /// measured EOM), while CapacityActual, the tail curve, the EW landmark
+ /// (EwToEomDistance, EarlyWarning.ReportedRemaining) and PhantomFreeAtEom are
+ /// re-measured. ReportedCapacityAtBom is a BOM quantity and is carried over from the header,
+ /// NOT re-measured.
+ ///
+ /// Returns the reassessed (or if re-measurement
+ /// was not possible) together with a verdict-free of how the key
+ /// figures moved. This is low-level DATA: the caller decides whether to keep the reassessed calibration
+ /// or schedule a full .
+ ///
+ ///
+ public (ITapeCalibration? Reassessed, TapeRecalibrationDelta Delta) Recalibrate(
+ ITapeCalibration existing, IProgress? progress = null)
+ {
+ ArgumentNullException.ThrowIfNull(existing);
+ ResetError();
+ IsAbortRequested = false;
+
+ RunGuard guard = new(this);
+ try
{
- if (Drive.LastErrorWin32 == WIN32_ERROR.NO_ERROR)
- SetError(WIN32_ERROR.ERROR_INVALID_PARAMETER);
- else
- SyncErrorFrom(Drive);
+ ITapeCalibration? reassessed = ResumeCore(progress);
+ if (reassessed is null)
+ return (null, default);
- LogErrorAsDebug("Calibration: drive reports zero block size");
+ var delta = new TapeRecalibrationDelta(
+ existing.EwToEomDistance, reassessed.EwToEomDistance,
+ existing.CapacityActual, reassessed.CapacityActual,
+ existing.PhantomFreeAtEom, reassessed.PhantomFreeAtEom);
+
+ m_logger.LogInformation(
+ "{Prefix}: Recalibrate done — EW {OldEw}→{NewEw} ({EwPct:+0.0%;-0.0%}), capacity {OldCap}→{NewCap} " +
+ "({CapPct:+0.0%;-0.0%}), phantom {OldPh}→{NewPh}",
+ LogPrefix, delta.OldEwToEomDistance, delta.NewEwToEomDistance, delta.EwShiftFraction,
+ delta.OldCapacityActual, delta.NewCapacityActual, delta.CapacityShiftFraction,
+ delta.OldPhantomFreeAtEom, delta.NewPhantomFreeAtEom);
+
+ return (reassessed, delta);
+ }
+ finally
+ {
+ guard.Restore();
+ }
+ }
+
+ #endregion
+
+ #region *** Resume core (shared by Resume & Recalibrate) ***
+
+ ///
+ /// Reads the on-tape header, restores state from the last good checkpoint, repositions, and writes on
+ /// to EOM. Shared by and (they differ only in what they
+ /// report to the caller). Assumes the neutralizing is already in effect.
+ ///
+ private TapeCalibration? ResumeCore(IProgress? progress)
+ {
+ if (!Drive.IsMediaLoaded)
+ {
+ SetError(WIN32_ERROR.ERROR_NO_MEDIA_IN_DRIVE);
+ LogErrorAsDebug("Resume: no media loaded");
return null;
}
- // The drive may round the requested max to its own granularity; re-derive the chunks so
- // ChunkSize/TailChunkSize stay consistent with what the hardware actually accepted.
- if (blockSize != plan.BlockSize)
+ if (!PrepareDrive(out TapeCalibrationPlan _, out uint blockSize))
+ return null;
+
+ // --- Read the header block (File 0 at BOM) to recover RunId, plan, and BOM capacity. ---
+ if (!Drive.Rewind())
{
- m_logger.LogWarning("{Prefix}: Calibration — drive adjusted block size {Requested} → {Effective}; re-deriving chunks",
- LogPrefix, plan.BlockSize, blockSize);
+ SyncErrorFrom(Drive);
+ LogErrorAsDebug("Resume: failed to rewind to header");
+ return null;
+ }
+
+ var recordBuffer = new byte[blockSize];
+ TapeCalibrationRunHeader? header = ReadRecord(recordBuffer);
+ if (header is null)
+ {
+ SetError(WIN32_ERROR.ERROR_INVALID_DATA);
+ LogErrorAsDebug("Resume: no valid calibration header on this cartridge — not resumable");
+ return null;
+ }
+ // Prefer the ORIGINAL plan (identical cadence/chunking); re-derive chunks if the drive now rounds
+ // the block size differently than when the run started.
+ TapeCalibrationPlan plan = header.Plan;
+ if (plan.BlockSize != blockSize)
plan = plan.WithBlockSize(blockSize);
+
+ long capacityReportedAtBom = header.CapacityReportedAtBom;
+
+ // --- Walk back from EOD to the last CRC-valid checkpoint of this run. ---
+ TapeCalibrationCheckpoint? checkpoint = FindLastCheckpoint(header.RunId, recordBuffer, out int nBack);
+ if (checkpoint is null)
+ {
+ SetError(WIN32_ERROR.ERROR_INVALID_DATA);
+ LogErrorAsDebug("Resume: no valid checkpoint found — run failed before the first checkpoint");
+ return null;
}
- // Now move to BOM and determine the capacity reported at BOM
- long capacityReportedAtBom;
- try
+ // --- Restore run state from the checkpoint. ---
+ var state = new RunState
{
- // Hardware compression OFF so incompressible bytes map 1:1 to tape position.
- Drive.SetHardwareCompression(false);
+ RunId = header.RunId,
+ BytesWritten = checkpoint.BytesWritten,
+ CheckpointIndex = checkpoint.Index,
+ EwPoint = checkpoint.EarlyWarning,
+ };
+ state.Samples.AddRange(checkpoint.Samples);
- // To get correct reported remaining at BOM, we must first write a small block to the tape
- // -- otherwise, in case the media isn't empty, the drive will report partial remaining!
- if (!Drive.WriteGapFile())
- {
- SyncErrorFrom(Drive);
- LogErrorAsDebug("Calibration: failed to write gap file");
- return null;
- }
+ m_logger.LogInformation(
+ "{Prefix}: Resume — run {RunId}, from checkpoint {Idx} at {Bytes} bytes ({Samples} samples restored, EW {Ew})",
+ LogPrefix, state.RunId, checkpoint.Index, state.BytesWritten, state.Samples.Count,
+ state.EwPoint is { } e ? $"{e.ActualWritten}/{e.ReportedRemaining}" : "(none)");
- // Now rewind again
- if (!Drive.Rewind())
- {
- SyncErrorFrom(Drive);
- LogErrorAsDebug("Calibration: failed to rewind");
- return null;
- }
+ // --- Reposition BOP-side of the FM preceding the good checkpoint and re-establish the boundary. ---
+ if (!Drive.FastforwardToEnd(MediaPartition.Content) || !Drive.MoveToNextFilemark(-nBack))
+ {
+ SyncErrorFrom(Drive);
+ LogErrorAsDebug("Resume: failed to reposition for continuation");
+ return null;
+ }
- // The reported-capacity side of the run intentionally tracks the DRIVER-facing Remaining
- // figure, not the true physical capacity, so emulations that still claim phantom free
- // space at hard EOM remain visible in the resulting calibration.
- capacityReportedAtBom = Drive.GetReportedContentRemaining();
- if (capacityReportedAtBom <= 0)
- {
- if (Drive.LastErrorWin32 == WIN32_ERROR.NO_ERROR)
- SetError(WIN32_ERROR.ERROR_INVALID_PARAMETER);
- else
- SyncErrorFrom(Drive);
+ using var records = new RecordBlockWriter(this, blockSize);
- LogErrorAsDebug("Calibration: drive reports zero capacity at BOM");
- return null;
- }
- }
- catch (Exception ex)
+ // Rewrite the FM + checkpoint block from the restored state, yielding a pristine boundary and
+ // reproducing the exact byte accounting the original run had at this point.
+ var reCheckpoint = new TapeCalibrationCheckpoint(
+ state.RunId, state.CheckpointIndex, state.BytesWritten, state.EwPoint, state.Samples);
+ if (!records.Emit(reCheckpoint, ref state.BytesWritten, writeLeadingFilemark: true))
{
- if (Drive.LastErrorWin32 == WIN32_ERROR.NO_ERROR)
- SetError(WIN32_ERROR.ERROR_IO_DEVICE);
- else
- SyncErrorFrom(Drive);
-
- m_logger.LogError(ex, "{Prefix}: Calibration: exception during setup", LogPrefix);
- throw; // we don't catch exceptions here -- the caller is reposible for handling them
+ LogErrorAsDebug("Resume: failed to rewrite boundary checkpoint");
+ return null;
}
+ state.CheckpointIndex++;
+
+ return RunLoop(plan, capacityReportedAtBom, state, records, progress);
+ }
+ #endregion
+
+ #region *** Shared write loop ***
+
+ ///
+ /// The common write-to-EOM loop: writes incompressible payload chunks, samples the driver's
+ /// ReportedRemaining against true bytes-written, captures the EW landmark, enters the fine TAIL phase
+ /// at EW / the last capacity fraction, emits BODY checkpoints at the planned interval, and builds the
+ /// final calibration at hard EOM. Drives only the public surface, so it works
+ /// on every backend (including virtual).
+ ///
+ private TapeCalibration? RunLoop(
+ TapeCalibrationPlan plan, long capacityReportedAtBom, RunState state,
+ RecordBlockWriter records, IProgress? progress)
+ {
// --- Prepare an incompressible payload chunk (whole blocks, BODY size — the largest we write) ---
using TapeWriteBufferPool pool = new();
var buffer = pool.Rent(plan.ChunkSize);
Random.Shared.NextBytes(buffer.Data()); // random ⇒ incompressible; reused every write (compression is off)
- // --- Two-phase sample cadence: coarse body, fine tail; the tail starts at EW or the last few percent ---
+ // --- Cadence: coarse body, fine tail; the tail starts at EW or the last few percent ---
long bodySampleInterval = plan.BodySampleInterval(capacityReportedAtBom);
long tailSampleInterval = plan.TailSampleInterval(capacityReportedAtBom);
long tailStartBytes = plan.TailStartBytes(capacityReportedAtBom);
+ long checkpointInterval = plan.CheckpointInterval(capacityReportedAtBom);
- // EXPERIMENTAL: probe the drive's own remaining figure over SCSI (LOG SENSE 0x31), alongside the
- // driver-reported one, so we can decide offline whether it dodges the tail quirks (esp. the LTO-3
- // collapse). Only meaningful on a Win32 LTO backend; a no-op (−1) otherwise.
- // Proven redundant across LTO-3/4/6, so off by default in Options. Can re-enable for new models
+ // EXPERIMENTAL LOG SENSE cross-check — gated (proven redundant across LTO-3/4/6), off by default.
TapeDriveWin32Backend? ltoBackend = Drive.Backend as TapeDriveWin32Backend;
bool probeLto = Options.CaptureLtoRemaining && ltoBackend?.IsLto == true;
- m_logger.LogInformation(
- "{Prefix}: Calibration start — profile '{Key}', reportedCapacityAtBom {Cap}, blockSize {Bs}, " +
- "bodyChunk {BChunk}, tailChunk {TChunk}, bodyInterval {BInt}, tailInterval {TInt}, tailStart {TStart}, " +
- "samples {Samples} (body {Body} + tail {Tail}), ltoProbe {Lto}",
- LogPrefix, Drive.DriveProfileKey, capacityReportedAtBom, blockSize,
- plan.ChunkSize, plan.TailChunkSize, bodySampleInterval, tailSampleInterval, tailStartBytes,
- plan.SampleCount, plan.BodySampleCount, plan.TailSampleCount, probeLto);
-
- // --- Write to hard EOM, sampling as we go ---
- var samples = new List<(long ActualWritten, long ReportedRemaining)>();
- var ltoSamples = new List<(long ActualWritten, long LtoRemaining)>();
- (long ActualWritten, long ReportedRemaining)? ewPoint = null;
- long bytesWritten = 0;
- long nextSample = 0;
-
- bool inTail = false;
- int currentChunk = plan.ChunkSize; // body chunk; shrinks to plan.TailChunkSize in the tail
- long sampleInterval = bodySampleInterval; // body cadence; tightens to tailSampleInterval in the tail
+ // Recompute tail state from position. This is SUFFICIENT — no need to persist "inTail" — ONLY
+ // because checkpoints are BODY-ONLY: a restored checkpoint is always strictly before tailStartBytes
+ // (it was written while !inTail), so this always yields false on resume, and the tail is re-entered
+ // naturally as writing continues (physical-EW runtime state was reset by RunGuard).
+ // ⚠ If checkpointing were ever allowed in the tail, the physical-EW-fired-early path would make this
+ // recompute wrong — we would then have to persist inTail (or IsPhysicalEarlyWarningSeen) in the checkpoint.
+ bool inTail = state.BytesWritten >= tailStartBytes; int currentChunk = inTail ? plan.TailChunkSize : plan.ChunkSize;
+ long sampleInterval = inTail ? tailSampleInterval : bodySampleInterval;
+
+ long nextSample = state.BytesWritten; // sample promptly on entry/resume
+ long nextCheckpoint = state.BytesWritten + checkpointInterval;
// Local: read the drive's native (LOG SENSE) remaining, record it against the driver figure, and
// trace any divergence — spotlighting the COLLAPSE (driver 0 while the drive still claims space).
@@ -251,7 +380,7 @@ long SampleLtoRemaining(long reportedRemaining, long actualWritten)
if (!ltoBackend.GetLtoRemainingCapacity(out long ltoRem, out _))
return -1L;
- ltoSamples.Add((actualWritten, ltoRem));
+ state.LtoSamples.Add((actualWritten, ltoRem));
long divergence = ltoRem - reportedRemaining;
if (reportedRemaining <= 0 && ltoRem > 0)
@@ -270,9 +399,6 @@ long SampleLtoRemaining(long reportedRemaining, long actualWritten)
return ltoRem;
}
- long ltoAtBom = SampleLtoRemaining(capacityReportedAtBom, 0L);
- samples.Add((ActualWritten: 0L, ReportedRemaining: capacityReportedAtBom));
-
try
{
while (true)
@@ -282,58 +408,59 @@ long SampleLtoRemaining(long reportedRemaining, long actualWritten)
int written = Drive.WriteDirect(buffer.Array, buffer.Offset, currentChunk,
out _ /* tapemark */, out _ /* ew (gated on reserve, unused here) */, out bool eom);
- bytesWritten += written;
+ state.BytesWritten += written;
// Capture the EW landmark exactly once, at first occurrence. We read Drive.IsEarlyWarning
// (set on every write regardless of the requested reserve) rather than the WriteDirect ew
// out-param, which is suppressed while the run holds no reserve.
- if (Drive.IsEarlyWarning && ewPoint is null)
+ if (Drive.IsEarlyWarning && state.EwPoint is null)
{
long rrEw = Drive.GetReportedContentRemaining();
- ewPoint = (bytesWritten, rrEw);
- long ltoEw = SampleLtoRemaining(rrEw, bytesWritten);
- samples.Add((bytesWritten, rrEw));
+ state.EwPoint = (state.BytesWritten, rrEw);
+ long ltoEw = SampleLtoRemaining(rrEw, state.BytesWritten);
+ state.Samples.Add((state.BytesWritten, rrEw));
progress?.Report(new TapeCalibrationProgress(
- bytesWritten, rrEw, Drive.GetCurrentBlock(), EarlyWarning: true, EndOfMedium: false, "early-warning")
+ state.BytesWritten, rrEw, Drive.GetCurrentBlock(), EarlyWarning: true, EndOfMedium: false, "early-warning")
{ LtoReportedRemaining = ltoEw });
m_logger.LogInformation("{Prefix}: Calibration EW at {Bytes} bytes (reportedRemaining {RR})",
- LogPrefix, bytesWritten, rrEw);
+ LogPrefix, state.BytesWritten, rrEw);
}
- // Enter the fine-grained TAIL phase at whichever comes first: the drive's physical EW, or the
- // last TailCapacityFraction of capacity. From here the write chunk shrinks and the cadence
- // tightens, so the EW→EOM stretch — where LTO reporting misbehaves — is densely sampled.
- if (!inTail && (Drive.IsPhysicalEarlyWarningSeen || bytesWritten >= tailStartBytes))
+ // Enter the fine-grained TAIL phase at whichever comes first: the drive's physical EW, or
+ // the last TailCapacityFraction of capacity. From here the write chunk shrinks, the cadence
+ // tightens, and CHECKPOINTS STOP (a failure here has already written ~95%).
+ if (!inTail && (Drive.IsPhysicalEarlyWarningSeen || state.BytesWritten >= tailStartBytes))
{
inTail = true;
currentChunk = plan.TailChunkSize;
sampleInterval = tailSampleInterval;
- nextSample = bytesWritten; // sample immediately at tail entry
+ nextSample = state.BytesWritten; // sample immediately at tail entry
m_logger.LogInformation(
"{Prefix}: Calibration entering TAIL at {Bytes} bytes (chunk {Chunk}, interval {Int}) — {Reason}",
- LogPrefix, bytesWritten, currentChunk, sampleInterval,
+ LogPrefix, state.BytesWritten, currentChunk, sampleInterval,
Drive.IsPhysicalEarlyWarningSeen ? "physical early warning" : "last capacity fraction");
}
if (eom)
{
long rrEom = Drive.GetReportedContentRemaining();
- long ltoEom = SampleLtoRemaining(rrEom, bytesWritten);
- samples.Add((bytesWritten, rrEom));
+ long ltoEom = SampleLtoRemaining(rrEom, state.BytesWritten);
+ state.Samples.Add((state.BytesWritten, rrEom));
progress?.Report(new TapeCalibrationProgress(
- bytesWritten, rrEom, Drive.GetCurrentBlock(), EarlyWarning: ewPoint is not null, EndOfMedium: true, "eom")
+ state.BytesWritten, rrEom, Drive.GetCurrentBlock(), EarlyWarning: state.EwPoint is not null, EndOfMedium: true, "eom")
{ LtoReportedRemaining = ltoEom });
m_logger.LogInformation("{Prefix}: Calibration EOM at {Bytes} bytes (reportedRemaining {RR}) — actual capacity",
- LogPrefix, bytesWritten, rrEom);
+ LogPrefix, state.BytesWritten, rrEom);
break;
}
- // No progress and no EOM ⇒ a genuine write error; stop.
+ // No progress and no EOM ⇒ a genuine write error; stop. Prior checkpoints stay on tape,
+ // so this run remains resumable from the last one.
if (written == 0)
{
SyncErrorFrom(Drive);
@@ -349,22 +476,45 @@ long SampleLtoRemaining(long reportedRemaining, long actualWritten)
return null;
}
- if (bytesWritten >= nextSample)
+ if (state.BytesWritten >= nextSample)
{
long rr = Drive.GetReportedContentRemaining();
- long lto = SampleLtoRemaining(rr, bytesWritten);
- samples.Add((bytesWritten, rr));
+ long lto = SampleLtoRemaining(rr, state.BytesWritten);
+ state.Samples.Add((state.BytesWritten, rr));
progress?.Report(new TapeCalibrationProgress(
- bytesWritten, rr, Drive.GetCurrentBlock(), EarlyWarning: ewPoint is not null, EndOfMedium: false,
+ state.BytesWritten, rr, Drive.GetCurrentBlock(), EarlyWarning: state.EwPoint is not null, EndOfMedium: false,
inTail ? "sampling-tail" : "sampling")
{ LtoReportedRemaining = lto });
nextSample += sampleInterval;
}
+
+ // Emit a resumable BODY checkpoint at the planned interval (never in the tail). The record
+ // is written as a FM + one full block; BytesWritten is advanced to count the block.
+ if (!inTail && state.BytesWritten >= nextCheckpoint)
+ {
+ var checkpoint = new TapeCalibrationCheckpoint(
+ state.RunId, state.CheckpointIndex, state.BytesWritten, state.EwPoint, state.Samples);
+
+ if (!records.Emit(checkpoint, ref state.BytesWritten, writeLeadingFilemark: true))
+ {
+ // A checkpoint write failed — the last checkpoint still stands, so resume remains
+ // possible. Surface the error and stop.
+ SyncErrorFrom(Drive);
+ LogErrorAsWarning("Calibration: failed to write checkpoint — stopping (run stays resumable)");
+ return null;
+ }
+
+ m_logger.LogTrace("{Prefix}: Calibration checkpoint {Idx} at {Bytes} bytes ({N} samples)",
+ LogPrefix, state.CheckpointIndex, checkpoint.BytesWritten, state.Samples.Count);
+
+ state.CheckpointIndex++;
+ nextCheckpoint = state.BytesWritten + checkpointInterval;
+ }
}
- long capacityActual = bytesWritten;
+ long capacityActual = state.BytesWritten;
if (capacityActual <= 0)
{
SetError(WIN32_ERROR.ERROR_IO_DEVICE);
@@ -373,8 +523,8 @@ long SampleLtoRemaining(long reportedRemaining, long actualWritten)
}
TapeCalibration calibration = TapeCalibration.FromMeasurements(
- Drive.DriveProfileKey, capacityReportedAtBom, capacityActual, samples, ewPoint,
- ltoSamples.Count > 0 ? ltoSamples : null);
+ Drive.DriveProfileKey, capacityReportedAtBom, capacityActual, state.Samples, state.EwPoint,
+ state.LtoSamples.Count > 0 ? state.LtoSamples : null);
m_logger.LogInformation(
"{Prefix}: Calibration done — actualCapacity {Act} ({Pct:F1}% of reported at BOM), " +
@@ -382,8 +532,8 @@ long SampleLtoRemaining(long reportedRemaining, long actualWritten)
LogPrefix, capacityActual,
calibration.ReportedCapacityAtBom > 0 ? 100.0 * capacityActual / calibration.ReportedCapacityAtBom : 0.0,
calibration.PhantomFreeAtEom,
- ewPoint is { } e ? $"{e.ActualWritten} bytes / RR {e.ReportedRemaining}" : "(none)",
- samples.Count, ltoSamples.Count);
+ state.EwPoint is { } e ? $"{e.ActualWritten} bytes / RR {e.ReportedRemaining}" : "(none)",
+ state.Samples.Count, state.LtoSamples.Count);
ResetError();
return calibration;
@@ -395,20 +545,302 @@ long SampleLtoRemaining(long reportedRemaining, long actualWritten)
else
SyncErrorFrom(Drive);
- m_logger.LogError(ex, "{Prefix}: Calibration: exception during setup", LogPrefix);
+ m_logger.LogError(ex, "{Prefix}: Calibration: exception during run", LogPrefix);
throw; // we don't catch exceptions here -- the caller is reposible for handling them
}
finally
{
- // Restore the caller's reserve and calibrations regardless of how the run ended.
- foreach (var c in savedCalibrations)
- Drive.AddCalibration(c);
+ pool.Return(buffer);
+ }
+ }
- Drive.SetEarlyWarning(savedReserve);
- Drive.ResetEarlyWarningRuntime();
+ #endregion
- pool.Return(buffer);
+ #region *** Setup helpers ***
+
+ ///
+ /// Positions on the content partition, sets the run block size, and re-derives the plan if the drive
+ /// rounded the requested (maximum) block size to its own granularity. Common to fresh runs and resumes.
+ ///
+ private bool PrepareDrive(out TapeCalibrationPlan plan, out uint blockSize)
+ {
+ plan = Options.ResolveFor(Drive);
+ blockSize = 0;
+
+ // Position at BOM of the content partition FIRST, so the new block size applies to it.
+ if (!Drive.MoveToPartition(MediaPartition.Content) || !Drive.Rewind())
+ {
+ SyncErrorFrom(Drive);
+ LogErrorAsDebug("Calibration: failed to rewind content partition");
+ return false;
+ }
+
+ if (!Drive.SetBlockSize(plan.BlockSize))
+ {
+ SyncErrorFrom(Drive);
+ LogErrorAsDebug("Calibration: failed to set block size");
+ return false;
+ }
+
+ blockSize = Drive.BlockSize; // effective value the drive accepted
+ if (blockSize == 0)
+ {
+ if (Drive.LastErrorWin32 == WIN32_ERROR.NO_ERROR)
+ SetError(WIN32_ERROR.ERROR_INVALID_PARAMETER);
+ else
+ SyncErrorFrom(Drive);
+
+ LogErrorAsDebug("Calibration: drive reports zero block size");
+ return false;
}
+
+ // The drive may round the requested max to its own granularity; re-derive the chunks so
+ // ChunkSize/TailChunkSize stay consistent with what the hardware actually accepted.
+ if (blockSize != plan.BlockSize)
+ {
+ m_logger.LogWarning("{Prefix}: Calibration — drive adjusted block size {Requested} → {Effective}; re-deriving chunks",
+ LogPrefix, plan.BlockSize, blockSize);
+
+ plan = plan.WithBlockSize(blockSize);
+ }
+
+ // Hardware compression OFF so incompressible bytes map 1:1 to tape position.
+ Drive.SetHardwareCompression(false);
+ return true;
+ }
+
+ ///
+ /// Determines the driver-reported remaining at BOM (writes a gap file first so a non-empty medium does
+ /// not report partial remaining), then rewinds to BOM ready for the header write. Only for FRESH runs.
+ ///
+ private bool EstablishBomCapacity(out long capacityReportedAtBom)
+ {
+ capacityReportedAtBom = 0;
+ try
+ {
+ // To get correct reported remaining at BOM, we must first write a small block to the tape
+ // -- otherwise, in case the media isn't empty, the drive will report partial remaining!
+ if (!Drive.WriteGapFile())
+ {
+ SyncErrorFrom(Drive);
+ LogErrorAsDebug("Calibration: failed to write gap file");
+ return false;
+ }
+
+ if (!Drive.Rewind())
+ {
+ SyncErrorFrom(Drive);
+ LogErrorAsDebug("Calibration: failed to rewind");
+ return false;
+ }
+
+ // The reported-capacity side of the run intentionally tracks the DRIVER-facing Remaining
+ // figure, not the true physical capacity, so emulations that still claim phantom free
+ // space at hard EOM remain visible in the resulting calibration.
+ capacityReportedAtBom = Drive.GetReportedContentRemaining();
+ if (capacityReportedAtBom <= 0)
+ {
+ if (Drive.LastErrorWin32 == WIN32_ERROR.NO_ERROR)
+ SetError(WIN32_ERROR.ERROR_INVALID_PARAMETER);
+ else
+ SyncErrorFrom(Drive);
+
+ LogErrorAsDebug("Calibration: drive reports zero capacity at BOM");
+ return false;
+ }
+ }
+ catch (Exception ex)
+ {
+ if (Drive.LastErrorWin32 == WIN32_ERROR.NO_ERROR)
+ SetError(WIN32_ERROR.ERROR_IO_DEVICE);
+ else
+ SyncErrorFrom(Drive);
+
+ m_logger.LogError(ex, "{Prefix}: Calibration: exception during BOM capacity setup", LogPrefix);
+ throw; // we don't catch exceptions here -- the caller is reposible for handling them
+ }
+
+ return true;
+ }
+
+ #endregion
+
+ #region *** Resume read helpers ***
+
+ /// Reads one record block at the current position and unpacks+CRC-checks it as .
+ private T? ReadRecord(byte[] recordBuffer) where T : class, ITapeSerializable
+ {
+ int read = Drive.ReadDirect(recordBuffer, 0, recordBuffer.Length, out _, out _);
+ if (read <= 0)
+ return null;
+
+ return TapeCalibrationRecord.Unpack(recordBuffer, read);
+ }
+
+ ///
+ /// Walks back from EOD one filemark at a time, reading the checkpoint block that each filemark
+ /// precedes, until it finds a CRC-valid checkpoint belonging to . Returns the
+ /// checkpoint and (via ) how many filemarks back it sits, so the caller
+ /// can reposition for the continuation. Returns at BOP (no resumable run).
+ ///
+ private TapeCalibrationCheckpoint? FindLastCheckpoint(Guid runId, byte[] recordBuffer, out int filemarksBack)
+ {
+ filemarksBack = 0;
+
+ for (int n = 1; ; n++)
+ {
+ if (!Drive.FastforwardToEnd(MediaPartition.Content))
+ {
+ SyncErrorFrom(Drive);
+ return null;
+ }
+
+ // Step back n filemarks; failing that, we have run out of checkpoints (hit BOP).
+ if (!Drive.MoveToNextFilemark(-n))
+ {
+ // BEGINNING_OF_PARTITION (or any positioning failure) ⇒ no more checkpoints to try.
+ ResetError();
+ return null;
+ }
+
+ // Forward over that filemark lands at the start of the checkpoint block it precedes.
+ if (!Drive.MoveToNextFilemark(1))
+ {
+ SyncErrorFrom(Drive);
+ return null;
+ }
+
+ TapeCalibrationCheckpoint? cp = ReadRecord(recordBuffer);
+ if (cp is not null && cp.RunId == runId)
+ {
+ filemarksBack = n;
+ return cp; // valid, same run → done
+ }
+
+ // Torn, foreign, or non-record block ⇒ step back one more filemark and retry.
+ m_logger.LogTrace("{Prefix}: Resume — checkpoint at -{N} FM invalid; stepping back", LogPrefix, n);
+ }
+ }
+
+ #endregion
+
+ #region *** Record block writer ***
+
+ ///
+ /// Writes calibration RECORDS (header, checkpoints) into single full-size blocks: the framed record
+ /// (see ) at the front, random padding for the rest. A fixed
+ /// random block is reused across records (padding content is immaterial with compression off; only the
+ /// front is overwritten per record), so no per-record allocation churn. The full block is counted into
+ /// the run's bytesWritten.
+ ///
+ private sealed class RecordBlockWriter : IDisposable
+ {
+ private readonly TapeCalibrator m_cal;
+ private readonly int m_blockSize;
+ private readonly byte[] m_block;
+ private bool m_tooLargeWarned;
+
+ public RecordBlockWriter(TapeCalibrator cal, uint blockSize)
+ {
+ m_cal = cal;
+ m_blockSize = (int)blockSize;
+ m_block = new byte[m_blockSize];
+ Random.Shared.NextBytes(m_block); // random padding, filled once
+ }
+
+ ///
+ /// Optionally writes a leading filemark, then writes as one full block,
+ /// advancing by the block size. Returns on
+ /// any write failure (error state set on the drive), or when the framed record does not fit one
+ /// block (checkpointing is then skipped — the run still completes, just not resumably).
+ ///
+ public bool Emit(ITapeSerializable record, ref long bytesWritten, bool writeLeadingFilemark)
+ {
+ byte[] frame = TapeCalibrationRecord.Pack(record);
+ if (frame.Length > m_blockSize)
+ {
+ if (!m_tooLargeWarned)
+ {
+ m_cal.m_logger.LogWarning(
+ "{Prefix}: Calibration record ({Len} B) exceeds block size ({Bs} B) — skipping checkpoints; run will not be resumable",
+ m_cal.LogPrefix, frame.Length, m_blockSize);
+ m_tooLargeWarned = true;
+ }
+ return true; // non-fatal: keep calibrating, just don't checkpoint
+ }
+
+ if (writeLeadingFilemark && !m_cal.Drive.WriteFilemark(1))
+ {
+ m_cal.SyncErrorFrom(m_cal.Drive);
+ return false;
+ }
+
+ // Overwrite only the front with the frame; the remainder stays random padding.
+ Array.Copy(frame, m_block, frame.Length);
+
+ int written = m_cal.Drive.WriteDirect(m_block, 0, m_blockSize, out _, out _, out _);
+ if (written != m_blockSize)
+ {
+ m_cal.SyncErrorFrom(m_cal.Drive);
+ return false;
+ }
+
+ bytesWritten += written;
+ return true;
+ }
+
+ public void Dispose() { /* nothing owned beyond the managed array */ }
+ }
+
+ #endregion
+
+ #region *** Run guard (neutralize + restore drive state) ***
+
+ ///
+ /// Neutralizes any active reserve and loaded calibrations for the duration of a run so
+ /// surfaces the RAW physical early warning the run must measure
+ /// (not a logical/calibrated remapping), and restores them afterward regardless of how the run ended.
+ ///
+ private readonly struct RunGuard
+ {
+ private readonly TapeCalibrator m_cal;
+ private readonly long m_savedReserve;
+ private readonly List m_savedCalibrations;
+
+ public RunGuard(TapeCalibrator cal)
+ {
+ m_cal = cal;
+ m_savedReserve = cal.Drive.EarlyWarning;
+ m_savedCalibrations = [.. cal.Drive.Calibrations];
+
+ cal.Drive.RemoveAllCalibrations();
+ cal.Drive.SetEarlyWarning(0); // clears reserve AND enables backend physical-EW reporting
+ cal.Drive.ResetEarlyWarningRuntime();
+ }
+
+ public void Restore()
+ {
+ foreach (var c in m_savedCalibrations)
+ m_cal.Drive.AddCalibration(c);
+
+ m_cal.Drive.SetEarlyWarning(m_savedReserve);
+ m_cal.Drive.ResetEarlyWarningRuntime();
+ }
+ }
+
+ #endregion
+
+ #region *** Abort ***
+
+ private bool CheckForAbort()
+ {
+ if (IsAbortRequested)
+ {
+ SetError(WIN32_ERROR.ERROR_CANCELLED);
+ m_logger.LogWarning("{Prefix}: Calibration aborted by caller", LogPrefix);
+ return true;
+ }
+ return false;
}
#endregion
diff --git a/TapeLibNET/Virtual/VirtualTapeMedia.cs b/TapeLibNET/Virtual/VirtualTapeMedia.cs
index 8b3edd4..bea3f89 100644
--- a/TapeLibNET/Virtual/VirtualTapeMedia.cs
+++ b/TapeLibNET/Virtual/VirtualTapeMedia.cs
@@ -406,6 +406,13 @@ public int WriteBlocks(byte[] buffer, int offset, int count)
return 0;
}
+ // Overwrite mode: truncate everything from the current position FIRST, so the capacity check
+ // below measures the real remaining from HERE — not the stale full-media figure. Writing after
+ // a backward seek (e.g. resuming a calibration in front of the last filemark) reclaims the space
+ // the trailing data occupied, exactly as real tape sets a new EOD on overwrite. At EOD this is a
+ // no-op, so the append/EOM path is unchanged.
+ TruncateFromCurrentPosition();
+
// Check capacity — enforcement always uses the TRUE remaining, never the (possibly
// optimistic) reported figure, so hard EOM lands at the real capacity.
if (TrueRemaining < count)
@@ -418,9 +425,6 @@ public int WriteBlocks(byte[] buffer, int offset, int count)
return 0;
}
- // Truncate any data after current position (overwrite mode)
- TruncateFromCurrentPosition();
-
int totalWritten = 0;
long streamOffset = m_stream.Position;
@@ -471,13 +475,6 @@ public bool WriteMark(TapeMarkType markType)
{
ResetError();
- // Check capacity - marks should not be written when media is full
- if (TrueRemaining <= 0)
- {
- SetError(WIN32_ERROR.ERROR_END_OF_MEDIA);
- return false;
- }
-
// Check ResumeWriteFromMarkOnly constraint
if (ResumeWriteFromMarkOnly && !CanResumeWrite())
{
@@ -485,14 +482,24 @@ public bool WriteMark(TapeMarkType markType)
return false;
}
+ // Overwrite mode: truncate from the current position FIRST (see WriteBlocks) so the capacity
+ // check reflects the true remaining measured from HERE. A mark written after a backward seek
+ // (resuming a calibration in front of the last filemark) reclaims the trailing space; at EOD
+ // this is a no-op.
TruncateFromCurrentPosition();
+ // Check capacity — a mark cannot be written when the medium is genuinely full at this position.
+ if (TrueRemaining <= 0)
+ {
+ SetError(WIN32_ERROR.ERROR_END_OF_MEDIA);
+ return false;
+ }
+
var mark = VirtualTapeBlock.CreateMark(m_currentBlock, markType);
m_virtualBlocks.Add(mark);
m_currentVirtualBlockIndex = m_virtualBlocks.Count; // Point past end
m_currentBlock++;
m_stateDirty = true;
-
return true;
}
From 0f6e4a7e98862ff700ef922212a5d26346a49da6 Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Mon, 17 Aug 2026 14:27:46 +0200
Subject: [PATCH 22/37] Add calibration modes to TapeLibNET service layer: new
calibration, resume calibration, and recalibrate (UI still to add). Extend
SimpleBox with additional image icons: Complete and Failed; update its usages
where applicable.
---
TapeLibNET.Tests/CalibrationResumeTests.cs | 30 ++
.../Services/ServiceCalibrationResumeTests.cs | 279 ++++++++++++++++++
.../Services/ServiceOperationRequest.cs | 20 +-
TapeLibNET/Services/ServiceOperationResult.cs | 26 ++
...ase.EW.cs => TapeServiceBase.Calibrate.cs} | 177 +++++++++--
TapeWinNET/Models/LogEntry.cs | 6 +-
TapeWinNET/SimpleBox.xaml.cs | 57 +++-
.../CalibrationProfilesViewModel.cs | 2 +-
TapeWinNET/ViewModels/CalibrationViewModel.cs | 2 +-
TapeWinNET/ViewModels/MainViewModel.Backup.cs | 4 +-
.../ViewModels/MainViewModel.Calibration.cs | 2 +-
TapeWinNET/ViewModels/MainViewModel.Log.cs | 6 +-
TapeWinNET/ViewModels/MainViewModel.Remote.cs | 10 +-
.../ViewModels/MainViewModel.Restore.cs | 4 +-
TapeWinNET/ViewModels/MainViewModel.cs | 32 +-
15 files changed, 599 insertions(+), 58 deletions(-)
create mode 100644 TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs
rename TapeLibNET/Services/{TapeServiceBase.EW.cs => TapeServiceBase.Calibrate.cs} (56%)
diff --git a/TapeLibNET.Tests/CalibrationResumeTests.cs b/TapeLibNET.Tests/CalibrationResumeTests.cs
index 9b51a50..d5c526d 100644
--- a/TapeLibNET.Tests/CalibrationResumeTests.cs
+++ b/TapeLibNET.Tests/CalibrationResumeTests.cs
@@ -336,5 +336,35 @@ public void Recalibrate_OnBlankCartridge_ReturnsNullReassessed()
Assert.Null(reassessed);
}
+ [Fact]
+ public void Recalibrate_AfterDriveBehaviorChange_ReportsLargeEwShift()
+ {
+ // Original drive behavior: a wide 8% early-warning zone.
+ var (drive, backend) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity, ewZonePercent: 8.0));
+
+ ITapeCalibration? original = new TapeCalibrator(drive) { Options = FastOptions() }.Run();
+ Assert.NotNull(original);
+ Assert.True(original!.EwToEomDistance > 0);
+
+ // Emulate a firmware update that SHRINKS the EW zone to 2%. ApplyEwProfileToMedia only reassigns
+ // the profile (it does not wipe content), so the resumable trail survives and the tail
+ // re-measurement now sees the new, later early warning. Shrinking (not growing) the zone keeps
+ // the new EW point AHEAD of the resume position, so it is measured cleanly rather than truncated.
+ backend.EmulatedEarlyWarning = VirtualTapeEwProfile.Lto4Like(Capacity, ewZonePercent: 2.0);
+
+ (ITapeCalibration? reassessed, TapeRecalibrationDelta delta) =
+ new TapeCalibrator(drive) { Options = FastOptions() }.Recalibrate(original!);
+
+ Assert.NotNull(reassessed);
+ AssertCurveWellFormed(reassessed!);
+
+ // The EW landmark moved substantially closer to EOM — the calibrator surfaces the behavior change
+ // as a large, verdict-free delta (the service layer, not the calibrator, judges it).
+ Assert.True(delta.NewEwToEomDistance < delta.OldEwToEomDistance,
+ $"EW→EOM should shrink after the zone shrank: {delta.OldEwToEomDistance} → {delta.NewEwToEomDistance}");
+ Assert.True(Math.Abs(delta.EwShiftFraction) > 0.10,
+ $"EW shift {delta.EwShiftFraction:P1} should be large after a drive-behavior change");
+ }
+
#endregion
}
diff --git a/TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs b/TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs
new file mode 100644
index 0000000..9cf1d57
--- /dev/null
+++ b/TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs
@@ -0,0 +1,279 @@
+using System;
+using TapeLibNET.Services;
+using TapeLibNET.Tests.Helpers;
+using TapeLibNET.Virtual;
+
+namespace TapeLibNET.Tests.Services;
+
+///
+/// Service-level coverage for the extended calibration surface: the
+/// dispatch (New / Resume / Recalibrate) through ,
+/// result tagging ( / /
+/// ), and host-pane logging — driven over small
+/// memory-backed virtual cartridges.
+///
+/// These complement the drive-level CalibrationResumeTests (which prove the mechanism itself);
+/// here the focus is the SERVICE plumbing: mode routing, verdict reporting, and mode-appropriate
+/// failure messages. The specific recalibration VERDICT is intentionally not pinned on an unchanged
+/// small drive — the EW→EOM distance is block-quantized, so an unchanged-profile recalibrate can move
+/// by a block; that is production-irrelevant but would make a strict "Holds" assertion flaky. Verdict
+/// behavior under a genuine drive-behavior change is covered separately (see the breach test).
+///
+///
+public class ServiceCalibrationResumeTests : ServiceTestBase
+{
+ private const long MB = 1024L * 1024;
+ private const long CalibrationCapacity = 64L * MB;
+
+ private static async Task<(TapeServiceBase service, TestTapeServiceHost host)> OpenCalibrationServiceAsync(
+ long capacity = CalibrationCapacity,
+ VirtualTapeDriveIoRate? ioRate = null,
+ VirtualTapeEwProfile? ewProfile = null)
+ {
+ var (service, host) = CreateService();
+
+ var vmd = new VirtualMediaDescriptor("memory-calibration", capacity, null, 0, InMemory: true);
+
+ Assert.True(await service.OpenVirtualDriveAsync(
+ VirtualTapeDriveCapabilities.WithFilemarksOnlyLargeBlocks,
+ vmd,
+ ioRate: ioRate,
+ ewProfile: ewProfile ?? VirtualTapeEwProfile.Lto4Like(capacity)),
+ $"OpenVirtualDriveAsync failed: {service.LastError}");
+
+ Assert.True(await service.LoadMediaAsync(),
+ $"LoadMediaAsync failed: {service.LastError}");
+
+ return (service, host);
+ }
+
+ private static TapeCalibrationOptions FastOptions() => new()
+ {
+ SampleCount = 40,
+ NumCheckpoints = 16,
+ };
+
+ // ── New calibration (extra coverage) ──────────────────────────────────────
+
+ [Fact]
+ public async Task ExecuteCalibrateAsync_New_ProducesMonotonicCurve_AndDefaultsToNewMode()
+ {
+ var (service, _) = await OpenCalibrationServiceAsync();
+ using (service)
+ {
+ var result = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(EjectWhenDone: false, Options: FastOptions()));
+
+ Assert.True(result.Success);
+
+ // The default mode is New, and the recalibration fields stay null for New/Resume.
+ Assert.Equal(CalibrationMode.New, result.Mode);
+ Assert.Null(result.RecalibrationDelta);
+ Assert.Null(result.RecalibrationVerdict);
+
+ var cal = result.Calibration;
+ Assert.NotNull(cal);
+ Assert.True(cal!.Curve.Count >= 2, "Curve should have at least two points");
+
+ // Curve is ascending in ReportedRemaining and monotonic non-decreasing in ActualRemaining.
+ for (int i = 1; i < cal.Curve.Count; i++)
+ {
+ Assert.True(cal.Curve[i].ReportedRemaining >= cal.Curve[i - 1].ReportedRemaining,
+ "ReportedRemaining axis must be ascending");
+ Assert.True(cal.Curve[i].ActualRemaining >= cal.Curve[i - 1].ActualRemaining,
+ "ActualRemaining must be monotonic non-decreasing");
+ }
+
+ Assert.NotNull(cal.EarlyWarning);
+ Assert.Equal(service.DriveProfileKey, result.ProfileKey);
+ }
+ }
+
+ // ── Resume ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ExecuteCalibrateAsync_Resume_OnTrailedCartridge_Completes()
+ {
+ var (service, host) = await OpenCalibrationServiceAsync();
+ using (service)
+ {
+ // A completed run leaves the resumable trail (header + body checkpoints) on the cartridge.
+ var first = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(EjectWhenDone: false, Options: FastOptions()));
+ Assert.True(first.Success);
+ Assert.Equal(CalibrationMode.New, first.Mode);
+
+ // Resume deterministically restarts from the last body checkpoint and re-measures the tail to
+ // EOM — no timing dependency, since the trail is already present.
+ var resumed = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
+ Mode: CalibrationMode.Resume));
+
+ Assert.True(resumed.Success, $"Resume failed: {resumed.Message}");
+ Assert.Equal(CalibrationMode.Resume, resumed.Mode);
+ Assert.NotNull(resumed.Calibration);
+ Assert.True(resumed.CapacityActual > 0);
+ Assert.Null(resumed.RecalibrationDelta); // Resume carries no recalibration delta
+ Assert.Null(resumed.RecalibrationVerdict);
+ Assert.True(host.ContainsMessage("Resuming calibration"));
+ }
+ }
+
+ [Fact]
+ public async Task ExecuteCalibrateAsync_Resume_OnBlankCartridge_FailsGracefully()
+ {
+ var (service, _ /*host*/) = await OpenCalibrationServiceAsync();
+ using (service)
+ {
+ // No run has been performed, so there is no header/trail to resume from.
+ var resumed = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
+ Mode: CalibrationMode.Resume));
+
+ Assert.False(resumed.Success);
+ Assert.Equal(CalibrationMode.Resume, resumed.Mode);
+ Assert.Contains("no resumable run", resumed.Message ?? string.Empty,
+ StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ // ── Recalibrate ───────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ExecuteCalibrateAsync_Recalibrate_ReportsDeltaVerdictAndAssessment()
+ {
+ var (service, host) = await OpenCalibrationServiceAsync();
+ using (service)
+ {
+ var first = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(EjectWhenDone: false, Options: FastOptions()));
+ Assert.True(first.Success);
+ Assert.NotNull(first.Calibration);
+
+ var recal = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
+ Mode: CalibrationMode.Recalibrate, ExistingCalibration: first.Calibration));
+
+ // Plumbing: the mode is tagged, the delta and verdict are populated, and the assessment is
+ // logged to the host pane. The specific verdict is drive-quantization-dependent on a small
+ // cartridge, so it is not pinned here (see the breach test for verdict behavior).
+ Assert.Equal(CalibrationMode.Recalibrate, recal.Mode);
+ Assert.NotNull(recal.RecalibrationDelta);
+ Assert.NotNull(recal.RecalibrationVerdict);
+ Assert.NotNull(recal.Calibration);
+ Assert.True(host.ContainsMessage("Recalibration assessment"));
+
+ // The delta reports the raw before/after values verdict-free.
+ var d = recal.RecalibrationDelta!.Value;
+ Assert.Equal(first.Calibration!.EwToEomDistance, d.OldEwToEomDistance);
+ Assert.Equal(first.Calibration.CapacityActual, d.OldCapacityActual);
+ }
+ }
+
+ [Fact]
+ public async Task ExecuteCalibrateAsync_Recalibrate_OnBlankCartridge_Fails()
+ {
+ var (service, _) = await OpenCalibrationServiceAsync();
+ using (service)
+ {
+ // A baseline to compare against exists, but the cartridge carries no calibration trail.
+ var existing = TapeCalibration.Apriori(service.DriveProfileKey, CalibrationCapacity);
+
+ var recal = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
+ Mode: CalibrationMode.Recalibrate, ExistingCalibration: existing));
+
+ Assert.False(recal.Success);
+ Assert.Equal(CalibrationMode.Recalibrate, recal.Mode);
+ Assert.Contains("no calibration trail", recal.Message ?? string.Empty,
+ StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ [Fact]
+ public async Task ExecuteCalibrateAsync_Recalibrate_WithoutExistingOrTrail_Fails()
+ {
+ var (service, _) = await OpenCalibrationServiceAsync();
+ using (service)
+ {
+ // No explicit existing calibration, none loaded on the drive, none in the store → the service
+ // refuses before touching the tape.
+ var recal = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
+ Mode: CalibrationMode.Recalibrate));
+
+ Assert.False(recal.Success);
+ Assert.Equal(CalibrationMode.Recalibrate, recal.Mode);
+ }
+ }
+
+ // ── Recalibrate — threshold breach + host confirm chain ──────────────────
+ // The breach is induced with a divergent STORED baseline (a pre-firmware-change calibration),
+ // which drives the same service judge → confirm → chain path as a live drive-behavior change while
+ // needing only public API. The drive-level profile-swap test proves the calibrator itself surfaces
+ // a real behavior shift as a large delta.
+
+ private static ITapeCalibration StaleBaseline(TapeServiceBase service) =>
+ // EW→EOM ≈ 22 MB and capacity/phantom both off — comfortably past every recalibration tolerance
+ // versus the drive's actual ~2.5 MB EW→EOM on the emulated LTO-4 profile.
+ TapeCalibration.Apriori(service.DriveProfileKey, CalibrationCapacity,
+ marginPercent: 5.0, remainingAtEwPercent: 40.0);
+
+ [Fact]
+ public async Task ExecuteCalibrateAsync_Recalibrate_Breach_Confirmed_ChainsFullRun()
+ {
+ var (service, host) = await OpenCalibrationServiceAsync();
+ using (service)
+ {
+ // A completed run leaves the resumable trail on the cartridge.
+ var first = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(EjectWhenDone: false, Options: FastOptions()));
+ Assert.True(first.Success);
+
+ // Script the host to CONFIRM the destructive full re-run.
+ host.ConfirmAnswers.Enqueue(true);
+
+ var recal = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
+ Mode: CalibrationMode.Recalibrate, ExistingCalibration: StaleBaseline(service)));
+
+ Assert.Equal(CalibrationMode.Recalibrate, recal.Mode);
+ Assert.Equal(RecalibrationVerdict.FullRecalibrationAdvised, recal.RecalibrationVerdict);
+ Assert.NotNull(recal.RecalibrationDelta);
+
+ // Confirmed → the service chained a fresh full run; the result is a NEW calibration re-tagged
+ // as a recalibration outcome, the confirm was consumed, and the chain was logged.
+ Assert.True(recal.Success, $"Chained full run should succeed: {recal.Message}");
+ Assert.NotNull(recal.Calibration);
+ Assert.True(host.ContainsMessage("Full recalibration confirmed"));
+ Assert.Empty(host.ConfirmAnswers);
+ }
+ }
+
+ [Fact]
+ public async Task ExecuteCalibrateAsync_Recalibrate_Breach_Declined_KeepsReassessed()
+ {
+ var (service, host) = await OpenCalibrationServiceAsync();
+ using (service)
+ {
+ var first = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(EjectWhenDone: false, Options: FastOptions()));
+ Assert.True(first.Success);
+
+ // No queued Confirm answer → the host returns its safe default (false): DECLINE the full re-run.
+ // This is the exact non-interactive guard that stops a quiet host launching a destructive run.
+ var recal = await service.ExecuteCalibrateAsync(
+ new CalibrateRequest(EjectWhenDone: false, Options: FastOptions(),
+ Mode: CalibrationMode.Recalibrate, ExistingCalibration: StaleBaseline(service)));
+
+ Assert.Equal(CalibrationMode.Recalibrate, recal.Mode);
+ Assert.Equal(RecalibrationVerdict.FullRecalibrationAdvised, recal.RecalibrationVerdict);
+
+ // Declined → no chained run; the reassessed calibration is kept, and the decline is logged.
+ Assert.True(recal.Success);
+ Assert.NotNull(recal.Calibration);
+ Assert.True(host.ContainsMessage("Full recalibration declined"));
+ Assert.False(host.ContainsMessage("Full recalibration confirmed"));
+ }
+ }
+}
diff --git a/TapeLibNET/Services/ServiceOperationRequest.cs b/TapeLibNET/Services/ServiceOperationRequest.cs
index 1c7a7d1..5ef81d5 100644
--- a/TapeLibNET/Services/ServiceOperationRequest.cs
+++ b/TapeLibNET/Services/ServiceOperationRequest.cs
@@ -70,6 +70,22 @@ public sealed record RestoreRequest(
// ── Calibrate ────────────────────────────────────────────────────────────────
+/// Which calibration operation a performs.
+public enum CalibrationMode
+{
+ /// A fresh, full calibration from BOM (destructive). The default.
+ New,
+
+ /// Continue a calibration interrupted by a transport fault, from the last on-tape checkpoint
+ /// on the currently loaded cartridge. Requires the (partially written) calibration cartridge.
+ Resume,
+
+ /// Fast re-measurement of the tail from the last checkpoint on a COMPLETE calibration
+ /// cartridge (e.g. after a firmware update / drive swap), producing a reassessed calibration and a
+ /// verdict on whether it still holds. Requires the calibration cartridge.
+ Recalibrate,
+}
+
///
/// Options for a destructive calibration run over the currently loaded medium.
///
@@ -79,7 +95,9 @@ public sealed record RestoreRequest(
///
public sealed record CalibrateRequest(
bool EjectWhenDone,
- TapeCalibrationOptions Options) : ServiceOperationRequest;
+ TapeCalibrationOptions Options,
+ CalibrationMode Mode = CalibrationMode.New,
+ ITapeCalibration? ExistingCalibration = null) : ServiceOperationRequest;
// ── List ─────────────────────────────────────────────────────────────────────
diff --git a/TapeLibNET/Services/ServiceOperationResult.cs b/TapeLibNET/Services/ServiceOperationResult.cs
index 6c10fd8..1d789dd 100644
--- a/TapeLibNET/Services/ServiceOperationResult.cs
+++ b/TapeLibNET/Services/ServiceOperationResult.cs
@@ -112,6 +112,21 @@ public sealed record RestoreResult : FileOperationResult
// ── Calibrate ────────────────────────────────────────────────────────────────
+///
+/// Service-level judgment of a run: whether the reassessed
+/// calibration is close enough to the previous one to keep using, or the drive has shifted enough that a
+/// full re-run is advised. This is policy (threshold-based), computed by TapeServiceBase from the
+/// raw that reports.
+///
+public enum RecalibrationVerdict
+{
+ /// The reassessed calibration is within tolerance of the existing one — keep using it.
+ Holds,
+
+ /// The drive's behavior shifted beyond tolerance — a full recalibration is advised.
+ FullRecalibrationAdvised,
+}
+
///
/// Summary statistics returned by a calibration operation.
///
@@ -122,6 +137,9 @@ public sealed record RestoreResult : FileOperationResult
///
public sealed record CalibrateResult : FileOperationResult
{
+ /// Which calibration mode produced this result.
+ public CalibrationMode Mode { get; init; } = CalibrationMode.New;
+
/// The calibration produced by the run, or on failure/abort.
public ITapeCalibration? Calibration { get; init; }
@@ -159,6 +177,14 @@ public sealed record CalibrateResult : FileOperationResult
/// Number of points in the calibrated curve.
public int CurvePointCount => Calibration?.Curve.Count ?? 0;
+ /// For : how the key figures moved versus the
+ /// existing calibration, or for New/Resume.
+ public TapeRecalibrationDelta? RecalibrationDelta { get; init; }
+
+ /// For : the service's threshold-based verdict, or
+ /// for New/Resume.
+ public RecalibrationVerdict? RecalibrationVerdict { get; init; }
+
///
public override bool IsFullSuccess => base.IsFullSuccess && Calibration is not null;
}
diff --git a/TapeLibNET/Services/TapeServiceBase.EW.cs b/TapeLibNET/Services/TapeServiceBase.Calibrate.cs
similarity index 56%
rename from TapeLibNET/Services/TapeServiceBase.EW.cs
rename to TapeLibNET/Services/TapeServiceBase.Calibrate.cs
index 52926a1..4013b7f 100644
--- a/TapeLibNET/Services/TapeServiceBase.EW.cs
+++ b/TapeLibNET/Services/TapeServiceBase.Calibrate.cs
@@ -1,18 +1,26 @@
using Windows.Win32.Foundation;
using Windows.Win32.System.SystemServices; // Helpers, Stopwatch
-
using TapeLibNET.Virtual;
-
using Stopwatch = Windows.Win32.System.SystemServices.Stopwatch;
namespace TapeLibNET.Services;
public partial class TapeServiceBase
{
- // ── Calibration ───────────────────────────────────────────────────────────
+ // ── Recalibration verdict thresholds ──────────────────────────────────────
+ // Beyond these SIGNED relative shifts (new vs. old) the reassessed calibration is deemed no longer
+ // trustworthy and a full re-run is advised. EW→EOM distance is the most critical figure, so it and
+ // capacity use a tight 1% band; the phantom figure is coarser and less critical, so 5%.
+ // These are POLICY constants — the calibrator itself stays verdict-free.
+ private const double c_recalEwShiftTolerance = 0.01; // 1%
+ private const double c_recalCapacityShiftTolerance = 0.01; // 1%
+ private const double c_recalPhantomShiftTolerance = 0.05; // 5%
+ // ── Calibration ───────────────────────────────────────────────────────────
///
- /// Executes a destructive calibration run against the currently loaded medium.
+ /// Executes a destructive calibration run against the currently loaded medium. The
+ /// selects a fresh run, a resume of an interrupted run, or a
+ /// fast recalibration of a complete calibration cartridge.
///
public Task ExecuteCalibrateAsync(CalibrateRequest request)
{
@@ -52,8 +60,12 @@ CalibrateResult MakeResult(
bool aborted = false,
bool failed = false,
string? message = null,
- Exception? error = null)
- => progressHandler?.GenerateResult(
+ Exception? error = null,
+ CalibrationMode mode = CalibrationMode.New,
+ TapeRecalibrationDelta? delta = null,
+ RecalibrationVerdict? verdict = null)
+ {
+ CalibrateResult baseResult = progressHandler?.GenerateResult(
calibration,
aborted: aborted,
failed: failed,
@@ -82,6 +94,15 @@ CalibrateResult MakeResult(
Error = error,
};
+ // Tag the mode/recalibration fields uniformly, regardless of which branch built baseResult.
+ return baseResult with
+ {
+ Mode = mode,
+ RecalibrationDelta = delta,
+ RecalibrationVerdict = verdict,
+ };
+ }
+
if (_drive is null || !_drive.IsMediaLoaded)
{
LastError = "Media not loaded";
@@ -110,7 +131,6 @@ CalibrateResult MakeResult(
{
Options = request.Options,
};
-
progressHandler = CreateCalibrateProgressHandler(calibrator, request, _drive.Capacity);
using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
@@ -125,29 +145,82 @@ CalibrateResult MakeResult(
LogInfo($"Calibration profile: >{_drive.DriveProfileKey}<");
LogInfoSub($"Reported capacity: {Helpers.BytesToStringLong(_drive.Capacity)}");
- OnStatusUpdate("Calibrating...");
+ // --- Dispatch the requested mode. New/Resume both yield an ITapeCalibration?; Recalibrate
+ // additionally yields a delta versus the supplied (or resolved) existing calibration. ---
+ ITapeCalibration? calibration;
+ TapeRecalibrationDelta? recalDelta = null;
+
timer.Restart();
- ITapeCalibration? calibration = calibrator.Run(progressHandler);
+ switch (request.Mode)
+ {
+ case CalibrationMode.Resume:
+ LogInfo("Resuming calibration from the last checkpoint on the cartridge...");
+ OnStatusUpdate("Resuming calibration...");
+ calibration = calibrator.Resume(progressHandler);
+ break;
+
+ case CalibrationMode.Recalibrate:
+ {
+ // Resolve the baseline to compare against: explicit → drive's active match → store.
+ ITapeCalibration? existing = request.ExistingCalibration
+ ?? _drive.Calibration
+ ?? CalibrationStore.Load(_drive.DriveProfileKey);
+
+ if (existing is null)
+ {
+ timer.Stop();
+ LastError = "Recalibrate needs an existing calibration to compare against";
+ LogErr(LastError);
+ OnStatusUpdate("Recalibration failed");
+ return MakeResult(failed: true, message: LastError, mode: CalibrationMode.Recalibrate);
+ }
+
+ LogInfo("Recalibrating: re-measuring the tail against the existing calibration...");
+ OnStatusUpdate("Recalibrating...");
+
+ (calibration, TapeRecalibrationDelta delta) = calibrator.Recalibrate(existing, progressHandler);
+ if (calibration is not null)
+ recalDelta = delta;
+ break;
+ }
+
+ case CalibrationMode.New:
+ default:
+ OnStatusUpdate("Calibrating...");
+ calibration = calibrator.Run(progressHandler);
+ break;
+ }
timer.Stop();
if (calibration is null)
{
LastError = calibrator.LastErrorMessage;
+
if (calibrator.LastError == (uint)WIN32_ERROR.ERROR_CANCELLED || calibrator.IsAbortRequested)
{
OnStatusUpdate("Calibration aborted");
LogFail("Calibration aborted");
- return MakeResult(aborted: true, message: "Calibration aborted");
+ return MakeResult(aborted: true, message: "Calibration aborted", mode: request.Mode);
}
+ // Resume/Recalibrate can legitimately fail to find a resumable trail on the cartridge;
+ // surface a mode-appropriate message so the caller can offer a fresh run instead.
+ string failMsg = request.Mode switch
+ {
+ CalibrationMode.Resume => $"Resume failed: no resumable run found on this cartridge ({LastError})",
+ CalibrationMode.Recalibrate => $"Recalibration failed: no calibration trail on this cartridge ({LastError})",
+ _ => LastError,
+ };
+
OnStatusUpdate("Calibration failed");
- LogErr($"Calibration failed: {LastError}");
- return MakeResult(failed: true, message: LastError);
+ LogErr($"Calibration failed: {failMsg}");
+ return MakeResult(failed: true, message: failMsg, mode: request.Mode);
}
OnStatusUpdate("Calibration complete");
LogInfo("Calibration summary:");
LogInfoSub($"Actual capacity: {Helpers.BytesToStringLong(calibration.CapacityActual)}");
+
if (calibration.EarlyWarning is { } ew)
{
LogInfoSub($"EW landmark: reported {Helpers.BytesToStringLong(ew.ReportedRemaining)}, " +
@@ -158,11 +231,52 @@ CalibrateResult MakeResult(
{
LogInfoSub("EW landmark: not observed during calibration");
}
+
LogInfoSub($"Curve points: {calibration.Curve.Count:N0}");
- LogOk("Calibration completed successfully");
+ // --- Recalibration: judge the shift, log it, and (if advised) offer a full re-run via host. ---
+ if (request.Mode == CalibrationMode.Recalibrate && recalDelta is { } d)
+ {
+ RecalibrationVerdict verdict = JudgeRecalibration(d);
+ LogRecalibrationDelta(d, verdict);
+
+ if (verdict == RecalibrationVerdict.FullRecalibrationAdvised)
+ {
+ // Ask the host to confirm a destructive full re-run. Non-interactive hosts return the
+ // default (false), so a quiet/CLI host never launches a multi-hour run unattended.
+ bool runFull = _host.Confirm(
+ "The drive's remaining-space behavior has shifted beyond tolerance since the last " +
+ "calibration. Run a FULL recalibration now? This is destructive and may take a long time.",
+ defaultAnswer: false);
+
+ if (runFull)
+ {
+ LogWarn("Full recalibration confirmed — running a fresh calibration from BOM...");
+
+ // Chain into the New path (fresh progress handler, timer, logging) with zero
+ // duplication, then re-tag the result as a recalibration outcome so the caller
+ // still sees the delta/verdict that triggered the re-run.
+ CalibrateResult full = ExecuteCalibrateCore(request with { Mode = CalibrationMode.New });
+ return full with
+ {
+ Mode = CalibrationMode.Recalibrate,
+ RecalibrationDelta = recalDelta,
+ RecalibrationVerdict = verdict,
+ };
+ }
+
+ LogWarn("Full recalibration declined — keeping the reassessed calibration; treat with caution");
+ }
+
+ LogOk("Recalibration completed");
+ progressHandler.CompleteProgress();
+ return MakeResult(calibration, message: "Recalibration completed",
+ mode: CalibrationMode.Recalibrate, delta: recalDelta, verdict: verdict);
+ }
+
+ LogOk("Calibration completed successfully");
progressHandler.CompleteProgress();
- return MakeResult(calibration, message: "Calibration completed");
+ return MakeResult(calibration, message: "Calibration completed", mode: request.Mode);
}
catch (TapeAbortRequestedException)
{
@@ -170,7 +284,7 @@ CalibrateResult MakeResult(
LastError = "Calibration aborted";
OnStatusUpdate("Calibration aborted");
LogFail("Calibration aborted");
- return MakeResult(aborted: true, message: LastError);
+ return MakeResult(aborted: true, message: LastError, mode: request.Mode);
}
catch (Exception ex)
{
@@ -178,7 +292,7 @@ CalibrateResult MakeResult(
LastError = ex.Message;
OnStatusUpdate("Calibration failed");
LogErr($"Calibration failed: {ex.Message}");
- return MakeResult(failed: true, message: ex.Message, error: ex);
+ return MakeResult(failed: true, message: ex.Message, error: ex, mode: request.Mode);
}
finally
{
@@ -186,6 +300,36 @@ CalibrateResult MakeResult(
}
}
+ // ── Recalibration judgment (policy) ───────────────────────────────────────
+ ///
+ /// Threshold-based verdict on a recalibration delta: whether the existing calibration still holds or
+ /// a full re-run is advised. Kept in the SERVICE layer (not the calibrator) because it is policy;
+ /// the thresholds are the c_recal* constants above.
+ ///
+ private static RecalibrationVerdict JudgeRecalibration(in TapeRecalibrationDelta delta)
+ => Math.Abs(delta.EwShiftFraction) > c_recalEwShiftTolerance
+ || Math.Abs(delta.CapacityShiftFraction) > c_recalCapacityShiftTolerance
+ || Math.Abs(delta.PhantomShiftFraction) > c_recalPhantomShiftTolerance
+ ? RecalibrationVerdict.FullRecalibrationAdvised
+ : RecalibrationVerdict.Holds;
+
+ /// Logs the before/after figures and the verdict of a recalibration to the host log pane.
+ private void LogRecalibrationDelta(in TapeRecalibrationDelta d, RecalibrationVerdict verdict)
+ {
+ LogInfo("Recalibration assessment:");
+ LogInfoSub($"EW→EOM distance: {Helpers.BytesToStringLong(d.OldEwToEomDistance)} → " +
+ $"{Helpers.BytesToStringLong(d.NewEwToEomDistance)} ({d.EwShiftFraction:+0.0%;-0.0%})");
+ LogInfoSub($"Actual capacity: {Helpers.BytesToStringLong(d.OldCapacityActual)} → " +
+ $"{Helpers.BytesToStringLong(d.NewCapacityActual)} ({d.CapacityShiftFraction:+0.0%;-0.0%})");
+ LogInfoSub($"Phantom @ EOM: {Helpers.BytesToStringLong(d.OldPhantomFreeAtEom)} → " +
+ $"{Helpers.BytesToStringLong(d.NewPhantomFreeAtEom)} ({d.PhantomShiftFraction:+0.0%;-0.0%})");
+
+ if (verdict == RecalibrationVerdict.Holds)
+ LogOk("Recalibration verdict: the existing calibration still holds");
+ else
+ LogWarn("Recalibration verdict: a full recalibration is advised");
+ }
+
///
/// Creates the progress handler for a calibration run.
///
@@ -211,7 +355,6 @@ public bool AddCalibration(ITapeCalibration calibration)
}
// ── Calibration autoload ──────────────────────────────────────────────────
-
private TapeCalibrationStore? _calibrationStore;
///
diff --git a/TapeWinNET/Models/LogEntry.cs b/TapeWinNET/Models/LogEntry.cs
index 718b0ef..f90fdde 100644
--- a/TapeWinNET/Models/LogEntry.cs
+++ b/TapeWinNET/Models/LogEntry.cs
@@ -42,10 +42,10 @@ public static class WarningLevelHelper
///
public static string GetIcon(WarningLevel level) => level switch
{
- WarningLevel.Error => "⚠",
+ WarningLevel.Error => "✖",
WarningLevel.Failed => "✗",
- WarningLevel.Warning => "⚠",
- WarningLevel.Info => "ℹ",
+ WarningLevel.Warning => "⚠\uFE0E", // gurantee monochrome glyph
+ WarningLevel.Info => "ℹ\uFE0E", // gurantee monochrome glyph
WarningLevel.Completed => "✓",
_ => string.Empty
};
diff --git a/TapeWinNET/SimpleBox.xaml.cs b/TapeWinNET/SimpleBox.xaml.cs
index 444f1b3..de4b0d3 100644
--- a/TapeWinNET/SimpleBox.xaml.cs
+++ b/TapeWinNET/SimpleBox.xaml.cs
@@ -1,5 +1,7 @@
-using System.Windows;
+using System.Numerics;
+using System.Windows;
using System.Windows.Controls;
+using System.Windows.Media;
namespace TapeWinNET;
@@ -10,17 +12,58 @@ public partial class SimpleBox : Window
{
private MessageBoxResult _result = MessageBoxResult.None;
+ ///
+ /// SimpleBox-only pseudo icons: a success checkmark and a failure cross.
+ /// Not part of the framework MessageBoxImage enum, outside of its range.
+ ///
+ public const MessageBoxImage ImageComplete = (MessageBoxImage)0x2000;
+ public const MessageBoxImage ImageFailed = (MessageBoxImage)0x2001;
+
+ private readonly record struct IconStyle(string Glyph, string? ResourceKey, Brush Fallback);
+
+ private static IconStyle StyleFor(MessageBoxImage icon) => icon switch
+ {
+ ImageComplete => new("✔", "WarningFg.Completed", Brushes.Green),
+ ImageFailed => new("✗", "WarningFg.Failed", new SolidColorBrush(Color.FromRgb(0xCC, 0x44, 0x00))),
+ MessageBoxImage.Information => new("ℹ\uFE0E", "WarningFg.Info", Brushes.Blue), // guarntee monochrome glyph
+ MessageBoxImage.Warning => new("⚠\uFE0E", "WarningFg.Warning", Brushes.Orange), // guarntee monochrome glyph
+ MessageBoxImage.Error => new("✖", "WarningFg.Error", Brushes.Red),
+ MessageBoxImage.Question => new("?", null, Brushes.SteelBlue),
+ _ => new("", null, Brushes.Transparent),
+ };
+
+ private Brush ResolveBrush(in IconStyle style)
+ {
+ if (style.ResourceKey is not null
+ && TryFindResource(style.ResourceKey) is Brush brush)
+ return brush;
+
+ return style.Fallback;
+ }
+
+ ///
+ /// Initializes a new instance of the class with the specified message, title, buttons, icon, default result, and options.
+ ///
+ /// The message to display in the message box.
+ /// The title of the message box.
+ /// The buttons to include in the message box.
+ /// The icon to display in the message box.
+ /// The default result of the message box.
+ /// The options for displaying the message box.
public SimpleBox(string message, string title,
- MessageBoxButton buttons,
- MessageBoxImage icon,
- MessageBoxResult defaultResult,
- MessageBoxOptions options)
+ MessageBoxButton buttons,
+ MessageBoxImage icon,
+ MessageBoxResult defaultResult,
+ MessageBoxOptions options)
{
InitializeComponent();
TitleText.Text = title;
MessageText.Text = message;
- IconText.Text = IconFromEnum(icon);
+
+ var style = StyleFor(icon);
+ IconText.Text = style.Glyph;
+ IconText.Foreground = ResolveBrush(style);
ApplyOptions(options);
@@ -54,6 +97,8 @@ private static string IconFromEnum(MessageBoxImage icon)
{
return icon switch
{
+ ImageComplete => "✔", // pairs with "✖" for Error
+ ImageFailed => "✖", // pairs with "✔" for ImageComplete
MessageBoxImage.Information => "ℹ",
MessageBoxImage.Warning => "⚠",
MessageBoxImage.Error => "✖",
diff --git a/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs b/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs
index 4fb9cc5..ad90fbc 100644
--- a/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs
+++ b/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs
@@ -144,7 +144,7 @@ private void Remove()
$"Failed to remove the calibration profile.\n\n{App.Settings.Calibrations.LastErrorMessage}",
"Remove Calibration Profile",
MessageBoxButton.OK,
- MessageBoxImage.Error);
+ SimpleBox.ImageFailed);
return;
}
diff --git a/TapeWinNET/ViewModels/CalibrationViewModel.cs b/TapeWinNET/ViewModels/CalibrationViewModel.cs
index ba6272f..c24d453 100644
--- a/TapeWinNET/ViewModels/CalibrationViewModel.cs
+++ b/TapeWinNET/ViewModels/CalibrationViewModel.cs
@@ -190,7 +190,7 @@ private void SaveProfile()
$"Failed to save the calibration profile.\n\n{App.Settings.Calibrations.LastErrorMessage}",
"Save Calibration",
MessageBoxButton.OK,
- MessageBoxImage.Error);
+ SimpleBox.ImageFailed);
return;
}
diff --git a/TapeWinNET/ViewModels/MainViewModel.Backup.cs b/TapeWinNET/ViewModels/MainViewModel.Backup.cs
index 2d4a067..32248f4 100644
--- a/TapeWinNET/ViewModels/MainViewModel.Backup.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.Backup.cs
@@ -200,7 +200,7 @@ private async Task ExecuteBackupAsync(BackupFormData request)
{
LogErr("Backup failed");
SimpleBox.Show("Backup failed. See log for details.", "Backup Failed",
- MessageBoxButton.OK, MessageBoxImage.Error);
+ MessageBoxButton.OK, SimpleBox.ImageFailed);
}
else if (operationResult is { WasAborted: true })
{
@@ -216,7 +216,7 @@ private async Task ExecuteBackupAsync(BackupFormData request)
else
{
SimpleBox.Show("Backup completed successfully!", "Backup Complete",
- MessageBoxButton.OK, MessageBoxImage.Information);
+ MessageBoxButton.OK, SimpleBox.ImageComplete);
}
}
catch (Exception ex)
diff --git a/TapeWinNET/ViewModels/MainViewModel.Calibration.cs b/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
index daef6d6..d6ea81c 100644
--- a/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
@@ -156,7 +156,7 @@ private async Task ExecuteCalibrationAsync(CalibrationViewModel viewModel)
if (operationResult is { HasFailed: true })
{
SimpleBox.Show("Calibration failed. See log for details.", "Calibration Failed",
- MessageBoxButton.OK, MessageBoxImage.Error);
+ MessageBoxButton.OK, SimpleBox.ImageFailed);
return;
}
diff --git a/TapeWinNET/ViewModels/MainViewModel.Log.cs b/TapeWinNET/ViewModels/MainViewModel.Log.cs
index 0f2fd12..ad89ead 100644
--- a/TapeWinNET/ViewModels/MainViewModel.Log.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.Log.cs
@@ -122,7 +122,7 @@ public bool ShowLogWarning
set { if (SetProperty(ref _showLogWarning, value)) RefreshLogFilter(); }
}
- /// Show Error + Failed entries.
+ /// Show Error + ImageFailed entries.
public bool ShowLogError
{
get => _showLogError;
@@ -285,7 +285,7 @@ private void FlushLogBuffer()
///
/// Priority-based pruning: removes the oldest entries of the lowest-priority
- /// warning levels first, preserving Error/Failed messages as long as possible.
+ /// warning levels first, preserving Error/ImageFailed messages as long as possible.
/// Rebuilds the collection in one pass to avoid O(n²) single-item removals.
///
private void PruneLogMessages()
@@ -294,7 +294,7 @@ private void PruneLogMessages()
if (toRemove <= 0)
return;
- // Removal priority: None → Info → Completed → Warning → Failed → Error
+ // Removal priority: None → Info → Completed → Warning → ImageFailed → Error
WarningLevel[] pruneOrder =
[
WarningLevel.None, WarningLevel.Info, WarningLevel.Completed,
diff --git a/TapeWinNET/ViewModels/MainViewModel.Remote.cs b/TapeWinNET/ViewModels/MainViewModel.Remote.cs
index 11dbb9e..0b1c30b 100644
--- a/TapeWinNET/ViewModels/MainViewModel.Remote.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.Remote.cs
@@ -263,7 +263,7 @@ private async Task OpenRemoteDriveAsync(object? parameter)
{
SimpleBox.Show(
$"Failed to open remote drive {driveNumber} on {settings.DisplayLabel}.\n\n{_tapeService.LastError}",
- "Open Remote Drive", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Open Remote Drive", MessageBoxButton.OK, SimpleBox.ImageFailed);
UpdateTreeForRemoteDriveOnly(driveNumber, settings);
return;
}
@@ -341,7 +341,7 @@ private async Task OpenRemoteVirtualDriveAsync()
{
SimpleBox.Show(
$"Failed to create remote virtual drive on {settings.DisplayLabel}.\n\n{_tapeService.LastError}",
- "Create Remote Virtual Drive", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Create Remote Virtual Drive", MessageBoxButton.OK, SimpleBox.ImageFailed);
UpdateTreeForRemoteDriveOnly(0, settings);
return;
}
@@ -378,7 +378,7 @@ private async Task OpenRemoteVirtualDriveAsync()
{
SimpleBox.Show(
$"Failed to open remote virtual volume on {settings.DisplayLabel}.\n\n{_tapeService.LastError}",
- "Open Remote Virtual Drive", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Open Remote Virtual Drive", MessageBoxButton.OK, SimpleBox.ImageFailed);
UpdateTreeForRemoteDriveOnly(0, settings);
return;
}
@@ -436,7 +436,7 @@ internal async Task FormatRemoteVirtualDriveAsync(FormatMediaViewModel formatVie
{
SimpleBox.Show(
$"Failed to recreate remote virtual drive on {settings.DisplayLabel}.\n\n{_tapeService.LastError}",
- "Format Remote Drive", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Format Remote Drive", MessageBoxButton.OK, SimpleBox.ImageFailed);
UpdateTreeForRemoteDriveOnly(0, settings);
return;
}
@@ -465,7 +465,7 @@ internal async Task FormatRemoteVirtualDriveAsync(FormatMediaViewModel formatVie
SelectMostRecentSet();
SimpleBox.Show("Remote virtual media formatted successfully!", "Format Complete",
- MessageBoxButton.OK, MessageBoxImage.Information);
+ MessageBoxButton.OK, SimpleBox.ImageComplete);
}
// ── Remote tree helpers ───────────────────────────────────────────────────
diff --git a/TapeWinNET/ViewModels/MainViewModel.Restore.cs b/TapeWinNET/ViewModels/MainViewModel.Restore.cs
index bfdf0a0..a17e3fd 100644
--- a/TapeWinNET/ViewModels/MainViewModel.Restore.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.Restore.cs
@@ -671,7 +671,7 @@ private async Task ExecuteRestoreAsync(RestoreFormData request)
{
LogErr($"{modeName} failed");
SimpleBox.Show($"{modeName} failed. See log for details.", $"{modeName} Failed",
- MessageBoxButton.OK, MessageBoxImage.Error);
+ MessageBoxButton.OK, SimpleBox.ImageFailed);
}
else if (operationResult is { WasAborted: true })
{
@@ -691,7 +691,7 @@ private async Task ExecuteRestoreAsync(RestoreFormData request)
else
{
SimpleBox.Show($"{modeName} completed successfully!",
- $"{modeName} Complete", MessageBoxButton.OK, MessageBoxImage.Information);
+ $"{modeName} Complete", MessageBoxButton.OK, SimpleBox.ImageComplete);
}
}
}
diff --git a/TapeWinNET/ViewModels/MainViewModel.cs b/TapeWinNET/ViewModels/MainViewModel.cs
index 0630d5d..74c87a9 100644
--- a/TapeWinNET/ViewModels/MainViewModel.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.cs
@@ -908,7 +908,7 @@ private async Task OpenPhysicalDriveWithUIAsync(int driveNumber)
if (!success)
{
SimpleBox.Show($"Failed to open drive {driveNumber}.\n\n{_tapeService.LastError}",
- "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Error", MessageBoxButton.OK, SimpleBox.ImageFailed);
}
return success;
}
@@ -950,7 +950,7 @@ private async Task ReadTOCWithUIAsync(
$"Failed to read TOC from media.\n\n{_tapeService.LastError}\n\n" +
"If you have a saved TOC file (.tapetoc), you can load it to access the media content.\n\n" +
"Would you like to load a TOC from file?",
- "TOC Read Failed", MessageBoxButton.YesNo, MessageBoxImage.Warning);
+ "TOC Read Failed", MessageBoxButton.YesNo, SimpleBox.ImageFailed);
return result == MessageBoxResult.Yes && await ImportTOCFromFileAsync();
}
@@ -1075,7 +1075,7 @@ private async Task EjectAsync()
if (!success)
{
SimpleBox.Show($"Failed to eject media.\n\n{_tapeService.LastError}",
- "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Error", MessageBoxButton.OK, SimpleBox.ImageFailed);
}
else
{
@@ -1119,12 +1119,12 @@ private async Task ExportTOCAsync()
if (success)
{
SimpleBox.Show($"TOC exported successfully to:\n{dialog.FileName}",
- "Export TOC", MessageBoxButton.OK, MessageBoxImage.Information);
+ "Export TOC", MessageBoxButton.OK, SimpleBox.ImageComplete);
}
else
{
SimpleBox.Show($"Failed to export TOC.\n\n{_tapeService.LastError}",
- "Export TOC", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Export TOC", MessageBoxButton.OK, SimpleBox.ImageFailed);
}
}
finally
@@ -1179,7 +1179,7 @@ private async Task ImportTOCFromFileAsync()
if (!success)
{
SimpleBox.Show($"Failed to import TOC from file.\n\n{_tapeService.LastError}",
- "Import TOC", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Import TOC", MessageBoxButton.OK, SimpleBox.ImageFailed);
}
return success;
}
@@ -1226,7 +1226,7 @@ private void ResetWindowPositions(object? parameter)
Settings.ResetHelpPaneLayout();
SimpleBox.Show("Window positions have been reset to defaults.",
- "Reset Window Positions", MessageBoxButton.OK, MessageBoxImage.Information);
+ "Reset Window Positions", MessageBoxButton.OK, SimpleBox.ImageComplete);
}
private void Exit(object? parameter)
@@ -1403,7 +1403,7 @@ private async Task RenameMediaAsync()
else if (_tapeService.LastError is not null)
{
SimpleBox.Show($"Failed to rename media.\n\n{_tapeService.LastError}",
- "Rename Failed", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Rename Failed", MessageBoxButton.OK, SimpleBox.ImageFailed);
}
}
finally
@@ -1466,7 +1466,7 @@ private async Task RenameBackupSetAsync(int setIndex)
else if (_tapeService.LastError is not null)
{
SimpleBox.Show($"Failed to rename backup set.\n\n{_tapeService.LastError}",
- "Rename Failed", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Rename Failed", MessageBoxButton.OK, SimpleBox.ImageFailed);
}
}
finally
@@ -1873,7 +1873,7 @@ private async Task OpenVirtualDriveAsync(VirtualDriveOpenRequest request)
{
SimpleBox.Show(
$"Failed to create virtual drive.\n\n{_tapeService.LastError}",
- "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Error", MessageBoxButton.OK, SimpleBox.ImageFailed);
UpdateTreeForDriveOnly(0);
}
return;
@@ -2031,10 +2031,10 @@ private async Task ExecuteFormatAsync(FormatMediaViewModel formatViewModel)
if (success)
SimpleBox.Show("Media formatted successfully!", "Format Complete",
- MessageBoxButton.OK, MessageBoxImage.Information);
+ MessageBoxButton.OK, SimpleBox.ImageComplete);
else
SimpleBox.Show($"Failed to format media.\n\n{_tapeService.LastError}",
- "Format Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Format Error", MessageBoxButton.OK, SimpleBox.ImageFailed);
}
catch (Exception ex)
{
@@ -2085,7 +2085,7 @@ private async Task ExecuteDeleteBackupSetsAsync(int deleteFromSetIndex)
if (!success)
{
SimpleBox.Show($"Failed to delete backup sets.\n\n{_tapeService.LastError}",
- "Delete Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Delete Error", MessageBoxButton.OK, SimpleBox.ImageFailed);
return;
}
@@ -2163,7 +2163,7 @@ private async Task FormatVirtualDriveAsync(FormatMediaViewModel formatViewModel)
if (!_tapeService.InsertVirtualMedia(newVmd, FileMode.Create, newEwProfile))
{
SimpleBox.Show($"Failed to create virtual media files.\n\n{_tapeService.LastError}",
- "Format Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Format Error", MessageBoxButton.OK, SimpleBox.ImageFailed);
return;
}
@@ -2177,7 +2177,7 @@ private async Task FormatVirtualDriveAsync(FormatMediaViewModel formatViewModel)
if (!await _tapeService.FormatMediaAsync(initiatorPartitionSize, formatViewModel.MediaName))
{
SimpleBox.Show($"Failed to format media.\n\n{_tapeService.LastError}",
- "Format Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Format Error", MessageBoxButton.OK, SimpleBox.ImageFailed);
return;
}
@@ -2189,7 +2189,7 @@ private async Task FormatVirtualDriveAsync(FormatMediaViewModel formatViewModel)
AddToVirtualDriveMru(newVmd.ContentPath);
SimpleBox.Show("Virtual media formatted successfully!", "Format Complete",
- MessageBoxButton.OK, MessageBoxImage.Information);
+ MessageBoxButton.OK, SimpleBox.ImageComplete);
}
catch (Exception ex)
{
From 6c2091d4463f9b4d8506782580d0ca91982ee07d Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Mon, 17 Aug 2026 18:17:58 +0200
Subject: [PATCH 23/37] Optimize drive movements in Resume Calibration path of
TapeCalibrator. Add more unit tests for Resume Calibration. Update the design
document.
---
TapeLibNET.Tests/CalibrationResumeTests.cs | 82 +++++++
TapeLibNET/TapeCalibrator.cs | 43 ++--
docs/Design-RemainingAndEw.md | 263 ++++++++++++++++++++-
3 files changed, 365 insertions(+), 23 deletions(-)
diff --git a/TapeLibNET.Tests/CalibrationResumeTests.cs b/TapeLibNET.Tests/CalibrationResumeTests.cs
index d5c526d..02c37f0 100644
--- a/TapeLibNET.Tests/CalibrationResumeTests.cs
+++ b/TapeLibNET.Tests/CalibrationResumeTests.cs
@@ -89,6 +89,40 @@ private static void AssertCurveWellFormed(ITapeCalibration cal)
}
}
+ ///
+ /// Corrupts the LAST checkpoint block on the trail in place, simulating a run torn while writing its
+ /// final checkpoint. Navigates to the block the last filemark precedes and overwrites it with random
+ /// bytes (no valid signature/CRC), which also truncates any trailing payload — leaving
+ /// … FM_{N-1} cp_{N-1} … FM_N [garbage] EOD. The header at BOM is untouched, so a resume still
+ /// finds a valid header, then must step back past the garbage (the -2 walk) to cp_{N-1}.
+ ///
+ private static void CorruptLastCheckpointBlocks(TapeDrive drive, int count = 1)
+ {
+ Assert.InRange(count, 1, int.MaxValue);
+ Assert.True(drive.SetBlockSize(drive.MaximumBlockSize));
+ int blk = (int)drive.BlockSize;
+
+ Assert.True(drive.FastforwardToEnd(MediaPartition.Content));
+
+ Assert.True(drive.MoveToNextFilemark(-1),
+ $"expected at least one checkpoint filemark on the aborted trail");
+
+ for (int i = 1; ; )
+ {
+ Assert.True(drive.MoveToNextFilemark(1)); // over the FM → start of the last checkpoint block
+
+ var garbage = new byte[blk];
+ new Random(4242).NextBytes(garbage); // random ⇒ no valid record signature/CRC
+ Assert.Equal(blk, drive.WriteDirect(garbage, 0, blk));
+
+ if (++i > count)
+ break;
+
+ Assert.True(drive.MoveToNextFilemark(-2),
+ $"expected at least {i} checkpoint filemarks on the aborted trail");
+ }
+ }
+
#endregion
#region *** Record framing (backend-independent) ***
@@ -278,6 +312,54 @@ public void Resume_RestoresPriorReserveAndCalibrations()
Assert.Contains(preloaded, drive.Calibrations);
}
+ [Fact]
+ public void Resume_RecoversFromTornLastCheckpoint()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ // Abort mid-body so SEVERAL body checkpoints are on tape (16 checkpoints, aborted at ~50% ⇒ ~8).
+ var run = new TapeCalibrator(drive) { Options = FastOptions() };
+ Assert.Null(run.Run(new AbortAfterBytes(run, Capacity / 2)));
+
+ // Tear the LAST checkpoint(s). The resume walk must reject it (CRC/signature fail) and step back one
+ // more checkpoint — the n≥2 (-2) path. The fact that this test COMPLETES also proves termination.
+ CorruptLastCheckpointBlocks(drive, count: 2); // corrupt 2 last of ~8 checkpoints
+
+ var resumer = new TapeCalibrator(drive) { Options = FastOptions() };
+ ITapeCalibration? resumed = resumer.Resume();
+
+ Assert.NotNull(resumed);
+ Assert.InRange(resumed!.CapacityActual, (long)(Capacity * 0.98), Capacity);
+ Assert.NotNull(resumed.EarlyWarning);
+ AssertCurveWellFormed(resumed);
+ }
+
+ [Fact]
+ public void Resume_OnForeignCartridgeWithRegularData_ReturnsNull()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ // The user's mix-up: a cartridge carrying ordinary filemark-delimited data (backup-like sets) but
+ // NO calibration header at BOM. Resume must reject it cleanly — caught by the header-at-BOM check
+ // in O(1), before any backward walk — returning null rather than misreading foreign blocks.
+ Assert.True(drive.MoveToPartition(MediaPartition.Content));
+ Assert.True(drive.Rewind());
+ Assert.True(drive.SetBlockSize(drive.MaximumBlockSize));
+
+ int blk = (int)drive.BlockSize;
+ var data = new byte[blk];
+ new Random(99).NextBytes(data);
+
+ for (int seg = 0; seg < 5; seg++)
+ {
+ Assert.Equal(blk, drive.WriteDirect(data, 0, blk));
+ Assert.True(drive.WriteFilemark(1));
+ }
+
+ ITapeCalibration? resumed = new TapeCalibrator(drive) { Options = FastOptions() }.Resume();
+ Assert.Null(resumed);
+ }
+
#endregion
#region *** Recalibrate (end-to-end) ***
diff --git a/TapeLibNET/TapeCalibrator.cs b/TapeLibNET/TapeCalibrator.cs
index 6b6cc2f..e866bed 100644
--- a/TapeLibNET/TapeCalibrator.cs
+++ b/TapeLibNET/TapeCalibrator.cs
@@ -300,12 +300,12 @@ private sealed class RunState
state.Samples.AddRange(checkpoint.Samples);
m_logger.LogInformation(
- "{Prefix}: Resume — run {RunId}, from checkpoint {Idx} at {Bytes} bytes ({Samples} samples restored, EW {Ew})",
- LogPrefix, state.RunId, checkpoint.Index, state.BytesWritten, state.Samples.Count,
+ "{Prefix}: Resume — run {RunId}, from checkpoint {Idx} ({Back} filemarks from EOD) at {Bytes} bytes ({Samples} samples restored, EW {Ew})",
+ LogPrefix, state.RunId, checkpoint.Index, nBack, state.BytesWritten, state.Samples.Count,
state.EwPoint is { } e ? $"{e.ActualWritten}/{e.ReportedRemaining}" : "(none)");
- // --- Reposition BOP-side of the FM preceding the good checkpoint and re-establish the boundary. ---
- if (!Drive.FastforwardToEnd(MediaPartition.Content) || !Drive.MoveToNextFilemark(-nBack))
+ // --- Reposition BOP-side of the FM preceding the good checkpoint (FindLastCheckpoint() brought us right after it) and re-establish the boundary. ---
+ if (!Drive.MoveToNextFilemark(-1))
{
SyncErrorFrom(Drive);
LogErrorAsDebug("Resume: failed to reposition for continuation");
@@ -687,22 +687,23 @@ private bool EstablishBomCapacity(out long capacityReportedAtBom)
{
filemarksBack = 0;
- for (int n = 1; ; n++)
+ // Seek to EOD ONCE, then walk backward checkpoint-by-checkpoint — a SINGLE reverse pass, so a
+ // wrong cartridge (ordinary backup sets, no calibration trail) is rejected in one traversal
+ if (!Drive.FastforwardToEnd(MediaPartition.Content))
{
- if (!Drive.FastforwardToEnd(MediaPartition.Content))
- {
- SyncErrorFrom(Drive);
- return null;
- }
+ SyncErrorFrom(Drive);
+ return null;
+ }
- // Step back n filemarks; failing that, we have run out of checkpoints (hit BOP).
- if (!Drive.MoveToNextFilemark(-n))
- {
- // BEGINNING_OF_PARTITION (or any positioning failure) ⇒ no more checkpoints to try.
- ResetError();
- return null;
- }
+ // Back up before the last filemark; none present ⇒ no resumable run (header-only / blank).
+ if (!Drive.MoveToNextFilemark(-1))
+ {
+ ResetError(); // BEGINNING_OF_PARTITION ⇒ nothing to resume
+ return null;
+ }
+ for (int n = 1; ; n++)
+ {
// Forward over that filemark lands at the start of the checkpoint block it precedes.
if (!Drive.MoveToNextFilemark(1))
{
@@ -719,6 +720,14 @@ private bool EstablishBomCapacity(out long capacityReportedAtBom)
// Torn, foreign, or non-record block ⇒ step back one more filemark and retry.
m_logger.LogTrace("{Prefix}: Resume — checkpoint at -{N} FM invalid; stepping back", LogPrefix, n);
+
+ // Reading advanced the head into this checkpoint's payload, so stepping back to the PREVIOUS
+ // checkpoint crosses TWO filemarks (this checkpoint's own FM + the previous one).
+ if (!Drive.MoveToNextFilemark(-2))
+ {
+ ResetError(); // BEGINNING_OF_PARTITION ⇒ no more checkpoints to try
+ return null;
+ }
}
}
diff --git a/docs/Design-RemainingAndEw.md b/docs/Design-RemainingAndEw.md
index 2bb12be..8230e05 100644
--- a/docs/Design-RemainingAndEw.md
+++ b/docs/Design-RemainingAndEw.md
@@ -20,6 +20,11 @@ the table of contents). Solving this properly required a three-part journey: **d
sense interpretation**, an **early-warning capability** (physical and logical), and a **calibration**
feature that measures each drive+media profile empirically.
+**CORRECTION** upon real measurements **"~28 GB / ~32 GB phantom at EOM" figure is wrong.** Real
+ `PhantomFreeAtEom` is 0–2.4 GB; the 28–32 GB number was the **EW→EOM runway** (`EwToEomDistance`,
+ quantity 7), not phantom (quantity 5). LTO-4: ~0.4 GB phantom, ~32 GB EW→EOM runway; LTO-3: 448 MB EW→EOM
+ runway, yet the reported remaining immediately collapses to 0 at EW.
+
---
## Part 1 — Low-level SCSI direct write + sensing [DONE]
@@ -154,6 +159,10 @@ crossed. Key design points:
poll counter are cleared in `ResetEarlyWarningRuntime()` on media load, unload, and close, so a stale latch
can never fake a landmark at BOT.
+**NOTE: SCSI `LOG SENSE` 0x31 is not an independent signal.** The direct `GetLtoRemainingCapacity()` probe returns the same value as the driver figure
+ (LTO-4/6) or collapses identically (LTO-3), so it carries no independent information; retained as an
+ off-by-default diagnostic (`CaptureLtoRemaining`).
+
---
## Part 3 — Calibration [DONE]
@@ -187,6 +196,11 @@ EW fires) is a stable *physical-position* constant for the profile, even though
percent per cartridge. At runtime, when *this* cartridge's EW fires, we anchor there and count forward — no
dependence on the calibration cartridge's exact capacity.
+**CORRECTION** upon real measurements **"~28 GB / ~32 GB phantom at EOM" figure is wrong.** Real
+ `PhantomFreeAtEom` is 0–2.4 GB; the 28–32 GB number was the **EW→EOM runway** (`EwToEomDistance`,
+ quantity 7), not phantom (quantity 5). LTO-4: ~0.4 GB phantom, ~32 GB EW→EOM runway; LTO-3: 448 MB EW→EOM
+ runway, yet the reported remaining immediately collapses to 0 at EW.
+
### `ITapeCalibration` / `TapeCalibration`
New file `TapeCalibration.cs`. The interface is opaque to the application (it only ever streams bytes and
@@ -219,6 +233,11 @@ points:
- **Conservative inversion** — because `ReportedRemaining` is many-to-one near the tail, ties keep the
smallest `ActualRemaining`; the curve simply does not extend below its floor, and EW covers below it.
+**NOTE on `EarlyWarning` field — add the collapse case.** Note that some drives (of LTO-3 generation and
+ earlier) collapse reported-remaining to 0 the instant EW fires while still accepting data; the runtime's
+ after-EW byte-count branch already handles this, and the curve now retains the collapse run for the
+ graph.
+
### `TapeCalibrator`
New file `TapeCalibrator.cs`, deriving from `TapeDriveHolder` for built-in error handling and
@@ -228,8 +247,8 @@ logging. Create-use-discard: `new TapeCalibrator(drive).Run()`. Backend-agnostic
- **Cooperative cancellation via `IsAbortRequested`** — a plain bool polled between writes (matching
`TapeFileAgent`), not a `CancellationToken`; async/await is the caller's concern.
- **Deterministic measurement** — sets max block size, disables hardware compression, writes a reused
- incompressible random chunk to hard EOM, samples `ReportedRemaining` against bytes-written at ~100 points
- across the medium (with a 256 MB floor), and captures the EW landmark at first occurrence.
+ incompressible random chunk to hard EOM, samples `ReportedRemaining` against bytes-written at ~`SampleCount` (default 1000) points split `TailSampleFraction` (default 0.40) into a fine tail over the
+ last `TailCapacityFraction` (default 0.05), entered at physical EW or the capacity mark, whichever first; and captures the EW landmark at first occurrence.
- **No calibration-run mode flag** — the calibrator simply **removes all loaded calibrations** for the
duration (restoring them in a `finally`) and resets EW runtime state, so `WriteDirect` naturally surfaces the
**raw physical** EW the run needs. One fewer piece of state on `TapeDrive`.
@@ -279,7 +298,7 @@ Runtime (every session):
| File | Role |
|---|---|
| `TapeDriveWin32Backend.lto-direct.cs` | SPTD write path, sense decode, chunking, adapter-capability probe, PEW. |
-| `TapeDriveWin32Backend.Lto.cs` | INQUIRY (vendor/product/**revision**), PEWS MODE SENSE/SELECT, READ POSITION EW status. |
+| `TapeDriveWin32Backend.Lto.cs` | INQUIRY (vendor/product/**revision**), PEWS MODE SENSE/SELECT, READ POSITION EW status, `GetLtoRemainingCapacity` / LOG SENSE 0x31. |
| `TapeDriveBackend.cs` | `Write(... pew, ew, eom)`; `SetEarlyWarning(bool)`; `Revision`; capacity-bucketed `ProfileKey`. |
| `TapeDrive.cs` | Logical EW mapping, block-anchored tail counting, multi-calibration set, `EstimateActualRemaining`. |
| `TapeCalibration.cs` | `ITapeCalibration` + JSON-backed `TapeCalibration` (`FromMeasurements`/`Apriori`/`LoadFrom`); `TranslateActualToReported` (Part 4.1). |
@@ -289,6 +308,10 @@ Runtime (every session):
| `Virtual/VirtualTapeMedia.EW.cs` | Per-cartridge EW state: `TrueRemaining`, reported `Remaining`, `IsInEarlyWarningZone` — Part 4.1. |
| `Virtual/VirtualTapeDriveBackend.EW.cs` | Backend EW config/surface: `EmulatedEarlyWarning`, mechanism overrides, `ew` in `Write` — Part 4.1. |
| `TapeWriteBuffer.cs` | Pooled page-aligned POH write buffer + pool; SPTD zero-copy fast path (Part 1A). |
+| `TapeCalibrationOptions.cs` | Calibration-run specifying and tuning knobs; `TapeCalibrationPlan` parameter instantiation for the run. |
+| `TapeCalibrationCheckpoint.cs` | `TapeCalibrationRunHeader`/`TapeCalibrationCheckpoint`/`TapeCalibrationRecord`/`TapeRecalibrationDelta` Enable resume calibration / recalibrate features (Part 6.4). |
+| `OnceLatch.cs` | `OnceLatch`/`OnceLatchGroup` — one-shot per-run trace latches for the LTO write path |
+| `TapeServiceBase.Calibrate.cs` | Calibration mode dispatch + recalibrate verdict policy (Part 6.6) |
---
@@ -698,7 +721,7 @@ consistent with existing backup/restore progress panels.
**Preparation step: implement calibration serivce**. Since we must implement calibration UI for both TapeWinNET
and TapeConNET, let's wrap it in a higher-level, threaded functionality on the level of `TapeLibNET.Services`,
-in the new file `TapeServiceBase.EW.cs`. Let's follow the same pattern `ServiceOperationRequest` -> operation ->
+in the new file `TapeServiceBase.Calibrate.cs`. Let's follow the same pattern `ServiceOperationRequest` -> operation ->
`ServiceOperationResult` used by Backup, Restore, and List service operations, which we can mirror for the new
methods `ExecuteCalibrateAsync()` (with optional media ejection at the end) -> `ExecuteCalibrateCore()`.
@@ -737,7 +760,7 @@ immediately improves the remaining-capacity figure for matching media.
- **Service operation: calibration**
- Adds `CalibrateRequest` / `CalibrateResult` to the existing `ServiceOperationRequest -> operation -> ServiceOperationResult` pattern.
- - Adds `ExecuteCalibrateAsync()` / `ExecuteCalibrateCore()` in `TapeServiceBase.EW.cs`.
+ - Adds `ExecuteCalibrateAsync()` / `ExecuteCalibrateCore()` in `TapeServiceBase.Calibrate.cs`.
- Introduces `ServiceCalibrateProgressHandler` to bridge calibration’s chunk-oriented progress into the existing operation-progress model used by the WPF overlay.
- Reuses the established cooperative abort flow by wiring service cancellation into `TapeCalibrator.IsAbortRequested`.
- Exposes minimal calibration-facing service surface needed by the UI (`DriveProfileKey`, active calibration, `AddCalibration()`).
@@ -886,6 +909,10 @@ known about the tail:
This is the entire justification of the calibration feature, and it is stated in the class documentation of
`TapeDrive`, `TapeCalibration` and `TapeFileBackupAgent`.
+**CORRECTION: The "Inflated capacity at BOM ≥ 0" assumption is disproved.** Note that the BOM error is
+ generation-dependent and can be **negative** (LTO-3 −3.8%, LTO-6 +0.19%); the "inflated capacity at BOM"
+ axis should read "capacity mis-report at BOM (may be negative = under-report)".
+
### 5.2 Emulation — two explicit anchors
```csharp
@@ -904,7 +931,7 @@ append-only usage the medium is designed for.
The Open Virtual Drive dialog exposes both axes with the shared %/MB/GB unit selector and a byte read-out:
**Phantom free at EOM** (default 4 %) and **Capacity overreport (BOM)** (default 0, listed last because it
-is usually left alone).
+is usually left alone). (S. CORRECTION above in 5.1.)
`ITapeCalibration` is deliberately used in **two opposite directions**, and both are documented as such: as
an *estimation* artifact (`TranslateReportedToActual`: reported → actual, at runtime) and as an *emulation* source
@@ -1025,3 +1052,227 @@ All on a virtual drive, in `TapeLibNET.Tests`:
Calibration JSON round-trips through `FormatId = "tapelibnet-cal/2"` and rejects unknown formats.
+---
+
+## Part 6 — Real-hardware calibration campaign + Resumable/Recalibratable runs [DONE]
+
+Parts 1–5 were validated entirely against the virtual backend. This part records what changed once the
+calibrator met **real LTO-3, LTO-4 and LTO-6 drives**, and the resumability feature those multi-hour runs
+made necessary.
+
+### 6.1 What the real drives taught us — and where the design doc was wrong
+
+Three findings overturned assumptions baked into earlier parts:
+
+- **The driver UNDER-reports at BOM at least as often as it over-reports.** The "inflated capacity at BOM"
+ axis (quantity 4 / `ReportedCapacityBoost`) was expected to be ≥ 0. Measured reality:
+ | Drive | Actual capacity | BOM error (reported − actual) | Phantom @ EOM | EW→EOM runway |
+ |---|---|---|---|---|
+ | LTO-3 (`QUANTUM ULTRIUM 3`) | 426 GB | **−16 GB (−3.8%)** under | 0 | **448 MB** |
+ | LTO-4 (`QUANTUM ULTRIUM 4`) | 845 GB | −6.4 GB (−0.76%) under | 383 MB | 31.8 GB |
+ | LTO-6 (`HP Ultrium 6`) | 2 539 GB | **+4.7 GB (+0.19%) OVER** | 2.39 GB | 110 GB |
+
+ The sign is generation-dependent and even flips (LTO-6 over-reports). **The curve model already handles
+ this natively** — `CapacityActual` is ground truth and the curve maps reported→actual, so an under-report
+ is simply points where actual > reported. No model change was needed; but the a-priori/emulation
+ assumption "boost ≥ 0" is now known to be wrong (see Part 7).
+
+- **The "~28 GB phantom at EOM" in earlier parts was a misread runway.** Real `PhantomFreeAtEom` is tiny
+ (0–2.4 GB). The 28–32 GB figure quoted throughout Parts 3/5 was actually the **EW→EOM runway**
+ (`EwToEomDistance`), i.e. quantity (7), mislabelled as phantom (quantity 5). The two are distinct: phantom
+ is *reported remaining still claimed at hard EOM*; runway is *actual bytes still writable after EW fires*.
+ Correction noted in §C below.
+
+- **LTO-3 COLLAPSES its reported-remaining to 0 the instant EW fires**, while still accepting ~448 MB of
+ data. LTO-4/6 decrement smoothly with a large runway. So the tail has **two shapes**: a smooth runway
+ (LTO-4/6) and a hard collapse (LTO-3 and, presumably, earlier generations). The runtime already tolerates
+ both — after physical EW it byte-counts from `EwToEomDistance` and ignores reported — but the calibration
+ *curve* and *graph* needed to represent the collapse (see §6.3).
+
+- **SCSI `LOG SENSE` 0x31 remaining ≡ the driver figure.** We added a direct `GetLtoRemainingCapacity()`
+ probe (LOG SENSE, Tape Capacity page 0x31) hoping the drive's own figure would dodge the driver quirks.
+ Across LTO-3/4/6 it proved **byte-identical to the driver value** (LTO-4/6) or **collapses in the same
+ instant** (LTO-3). Verdict: SCSI offers no independent signal and no escape from the collapse — the
+ driver figure *is* the drive's own figure. The probe is retained but gated **off by default**
+ (`CalibrationOptions.CaptureLtoRemaining`, default false); the parallel `LtoRemainingCurve` is only
+ serialized when captured.
+
+### 6.2 Two-phase, tail-weighted sampling [DONE]
+
+A uniform ~100–1000 point cadence proved far too coarse in the EW→EOM tail — the one region where accuracy
+matters. `TapeCalibrationOptions`/`TapeCalibrationPlan` now split the sample budget:
+
+- **BODY** — coarse cadence across the first `(1 − TailCapacityFraction)` of capacity.
+- **TAIL** — a reserved `TailSampleFraction` of the budget (default **0.40**) spent over the last
+ `TailCapacityFraction` (default **0.05**) of capacity, at a proportionally finer chunk. The tail is
+ entered at **whichever comes first**: the drive's physical EW, or the last-few-percent capacity mark. The
+ capacity trigger guarantees a dense tail even on LTO-3, whose physical EW fires only ~0.1% before EOM.
+- Small (virtual) media floor the tail chunk to a single default block, as before.
+
+`SampleCount` default raised 100 → 1000. On LTO-6 the tail trigger fired at 127 GB remaining (before
+physical EW at 110 GB), densely sampling the whole runway.
+
+### 6.3 Collapse handling in the curve, translations, and graph [DONE]
+
+- **`FromMeasurements`** no longer dedups the `reported == 0` collapse run out of existence. The
+ reported→actual curve is a function *of reported*, so a run of points all at `reported = 0` is many-to-one
+ and was previously collapsed to the single `(0,0)` EOM point — silently discarding the LTO-3 tail. The
+ dedup now retains every `reported == 0` point.
+- **`TranslateReportedToActual`** (formerly `TranslateRemaining`) and **`TranslateActualToReported`** were
+ hardened against equal-key brackets (return the conservative endpoint; never divide by zero). Retaining
+ the collapse run also *fixed a latent bug* in `TranslateActualToReported`: it previously ramped reported
+ from 0 up to the first post-collapse anchor across the collapse zone; it now correctly returns 0 there,
+ which in turn makes `VirtualTapeEwProfile.FromCalibration` reproduce the collapse faithfully.
+- **`VirtualTapeEwProfile.FromCalibration`** gained a floored, magnified EW zone
+ (`MinEmulatedEarlyWarningZone`, default 16 GB) with a piecewise body/tail rescale, so the (physically
+ constant, ~0.5 GB) collapse/EW region is observable on tiny test cartridges instead of shrinking to a few
+ KB. Both actual- and reported-remaining ride the same map, preserving the over-report shape after rescale.
+- **`CalibrationCurveControl`** was flipped to **X = ActualRemaining** (full capacity left → EOM right),
+ **Y = ReportedRemaining**, with a dashed identity line so under/over-report, the LTO-3 collapse (vertical
+ plunge to 0 at EW), and the phantom (step at EOM) are all directly visible. EW is marked warning-orange,
+ EOM error-red; a blue "current point" tracks the cursor with an Actual · Reported readout in the free
+ top-right corner. The old reported-remaining X-axis hid the collapse (it piled up at X=0).
+
+### 6.4 Resumable & recalibratable runs [DONE]
+
+Multi-hour runs made a transport fault (a real bus reset ended the first LTO-6 attempt at ~530 GB) too
+expensive to restart from BOM. The calibrator now lays down a **self-describing on-tape trail** and can
+**resume** from it, or **recalibrate** a complete cartridge cheaply after a firmware update / drive swap.
+The cartridge is the single source of truth — **no host-side sidecar** (rejected as redundant: the tape
+already survives a reboot, and the fastest reposition is EOD→back-space regardless of any host index).
+
+- **On-tape layout (single filemark before each checkpoint):**
+ ```
+ [header][payload][checkpoint 0][payload][checkpoint 1][payload]…
+ ```
+ A filemark immediately *precedes* each checkpoint block, so the resume walk always lands at a
+ checkpoint-block start — never inside payload gibberish, even if a checkpoint write was torn.
+- **Records** (`TapeCalibrationRunHeader`, `TapeCalibrationCheckpoint`) are `ITapeSerializable`, framed with
+ a **CRC-32 trailer** (reusing `HashingStream`/`Crc32`) so a torn record is detected and the resume walk
+ steps back. Each record occupies one full calibration block (framed record at the front, random padding
+ for the rest — compression is off, so padding content is immaterial; the whole block is counted in
+ `bytesWritten`, faithfully reflecting real set-delimited overhead). Checkpoints are **cumulative and
+ self-contained**: one valid read fully restores run state.
+- **Checkpoints are BODY-ONLY.** `NumCheckpoints` (default **128**, ~1% granularity; set low, e.g. 8, for
+ virtual-drive tests) are laid across the body; the tail is never checkpointed (a failure there has already
+ written ~95%). This invariant is what lets the runtime recompute `inTail` on resume from position alone —
+ a restored checkpoint is always strictly pre-tail.
+- **API** — three verbs over a shared private `RunLoop`:
+ - `Run()` — fresh from BOM (header + body checkpoints).
+ - `Resume()` — read header, walk back EOD → `−n/+1` filemarks to the last CRC-valid checkpoint of this
+ `RunId`, restore state, rewrite the boundary checkpoint, continue to EOM. Returns null if no resumable
+ run is found. **Resume is itself resumable** (fail → resume → fail → resume converges).
+ - `Recalibrate(existing)` — resume from the last (pre-tail) checkpoint, re-measure only the tail, and
+ return `(ITapeCalibration?, TapeRecalibrationDelta)`. The body curve is *reused* from the trail and
+ auto-translates to the freshly measured EOM (`FromMeasurements` recomputes `actual = newCapacity −
+ written`); `CapacityActual`, the tail curve, the EW landmark and `PhantomFreeAtEom` are re-measured;
+ `ReportedCapacityAtBom` (a BOM quantity) is carried over from the header.
+- **The calibrator stays verdict-free and match-free.** `Recalibrate` reports a raw `TapeRecalibrationDelta`
+ (old/new EW-distance, capacity, phantom + signed fractions); it does **not** judge the result, and it does
+ **not** perform drive-profile matching — both are the caller's/service's concern (Part 3's layering).
+
+### 6.5 A genuine `VirtualTapeMedia` bug, surfaced by resume [DONE]
+
+Resume repositions **in front of the last filemark on a full tape** and overwrites. `WriteBlocks` and
+`WriteMark` checked `TrueRemaining` **before** `TruncateFromCurrentPosition()` reclaimed the trailing
+space, so an overwrite-in-front-of-tail wrongly failed with `END_OF_MEDIA` even though it was about to free
+the entire tail. This latent bug had lain dormant because nothing before ever overwrote near a full tape.
+**Fix: truncate first, then check capacity** — so the check measures the true room *from the current
+position*, exactly as real tape sets a new EOD on overwrite; at EOD truncation is a no-op, so the append/EOM
+path (and all ~1700 legacy tests) is unchanged. Pinned by two dedicated `VirtualDriveBasicTests` (a
+genuine-EOD write/mark still refused; an overwrite-after-backward-seek now succeeds).
+
+### 6.6 Service integration [DONE]
+
+`CalibrateRequest` gained `Mode` (`CalibrationMode.New | Resume | Recalibrate`, default New) and an optional
+`ExistingCalibration`. `CalibrateResult` gained `Mode`, `RecalibrationDelta`, and `RecalibrationVerdict`.
+`ExecuteCalibrateCore` dispatches to the three calibrator verbs and, for Recalibrate, applies the
+**verdict policy** (which the calibrator deliberately does not): threshold constants (EW 1%, capacity 1%,
+phantom 5%) → `RecalibrationVerdict.{Holds, FullRecalibrationAdvised}`, logs a before/after assessment to
+the host pane, and on breach asks `ITapeServiceHost.Confirm(...)` before chaining a fresh full run. A
+non-interactive host returns the safe default (false), so a quiet/CLI host never launches a destructive
+multi-hour run unattended.
+
+### 6.7 Test coverage added this session
+
+- `CalibrationResumeTests` — record CRC framing round-trips + corruption detection; resume completes an
+ aborted run; resume ≈ uninterrupted run; **fail→resume→fail→resume convergence**; resume on blank →
+ null; reserve/calibration restoration; recalibrate delta small on a stable drive; recalibrate reports a
+ **large EW shift after a live `EmulatedEarlyWarning` profile swap** (emulating post-firmware drift).
+- `VirtualDriveBasicTests` — the two overwrite-near-EOM regressions (§6.5).
+- `ServiceCalibrationResumeTests` — New/Resume/Recalibrate dispatch + result tagging; mode-appropriate
+ failure messages; the **confirm-chain capstone** (breach via a divergent stored baseline; host `Confirm`
+ scripted true → chains a full run; empty queue → declines and keeps the reassessed calibration).
+
+---
+
+## Part 7 — Remaining tasks
+
+### 7.1 UI for Resume & Recalibrate — TapeWinNET (WPF) and TapeConNET (CLI)
+
+Surface the new `CalibrationMode` in both apps, matching the service extension.
+
+- **WPF (`CalibrateWindow`):** replace the implicit New-only flow with a mode selector — a radio group:
+ ```
+ Calibration mode:
+ (•) New (default)
+ ( ) Resume previous run [requires cartridge with a resumable run that matches this drive]
+ ( ) Recalibrate (tail check) [requires cartridge with a saved calibration run that matches this drive]
+ ```
+ Offer a button ("Inspect media") to quickly validate the two media-dependent options by probing the cartridge header via a lightweight service call
+ and inspecting the `CalibrationStore` for a matching profile; show a one-line result ("Resumable run found: 41%
+ written, HP Ultrium 6, firmware 35GD→35GE"). Wire the selection to `CalibrateRequest.Mode`; on a
+ `FullRecalibrationAdvised` verdict, route the service's `Confirm` to a WPF dialog; render
+ `RecalibrationDelta`/`RecalibrationVerdict` in `CalibrationWindow` (before/after rows + verdict banner).
+- **CLI (`TapeConNET`):** add `--calibrate-resume` and `--calibrate-recheck` (or `--calibrate
+ --mode=resume|recalibrate`); map `ITapeServiceHost.Confirm` to a Y/N prompt (or `--yes` for
+ non-interactive); print the recalibration assessment table and verdict.
+
+### 7.2 Update a-priori and "LTO-4-like" profiles from the real-hardware data
+
+The `Apriori` factory (`marginPercent 5`, `remainingAtEwPercent 7`) and `Lto4Like` defaults predate the real
+measurements and are now known to be off:
+
+- **Runway (`EwToEomDistance`)** is ~4% of capacity on LTO-4/6, not 7%; on LTO-3 it is ~0.1%.
+- **Phantom** is < 0.1% on real drives, not the 4–5% assumed.
+- **BOM error is small and generation-dependent, and can be NEGATIVE** (LTO-3 −3.8%, LTO-6 +0.19%). The
+ "boost ≥ 0" assumption in `ReportedRemainingAnchors`/`Apriori` should be relaxed to allow a negative
+ boost (under-report), and virtual emulation should be able to reproduce it.
+- **Preferred direction:** rather than hand-tuning synthetic constants, **ship measured per-generation
+ reference calibrations** (LTO-3/4/6 now in hand) as embedded resources, loaded through the same
+ `TapeCalibration.LoadFrom` path; a fresh run overrides. Retune the synthetic `Apriori`/`Lto4Like` only as
+ a last-resort fallback for unmeasured generations.
+
+### 7.3 Rework how an a-priori profile is assigned when no calibration exists
+
+Today `SelectEarlyWarningMechanism` synthesizes an `Apriori` from nominal capacity whenever no measured
+profile matches. With real data available, revisit the whole a-priori story:
+
+- Prefer a **shipped per-generation reference profile** (7.2) matched by vendor/product/generation over the
+ blind linear `Apriori`, so an un-calibrated-but-known drive still gets realistic EW behavior.
+- Fall back to the synthetic `Apriori` only for genuinely unknown drives, with corrected defaults (7.2).
+- Decide the matching granularity for reference profiles (generation-level, ignoring firmware and exact
+ capacity bucket) versus the exact-key matching used for measured calibrations — likely a looser
+ `IgnoreFirmware`/generation match for reference profiles, exact for measured ones.
+
+### 7.4 Evaluate pre-LTO drives for EW support — "LTO generation 0" (future)
+
+Investigate whether older linear/helical drives that TapeNET already supports — **AIT, DAT-320, SDLT /
+DLT-V4** — expose an early-warning mechanism and tolerate SCSI pass-through control/direct commands the same
+way LTO does. If any do, the whole EW / `EstimateActualRemaining` machinery could be extended to them,
+a real value-add for those users. Scope:
+
+- **Probe for EW capability** per drive family: does a `WRITE(6)` over SPTD surface an EOM-bit/early-warning
+ sense before hard EOM? Do `LOG SENSE`/`READ POSITION` behave? Some of these are helical-scan (AIT/DAT) and
+ may not have an LTO-style EW zone at all.
+- **If EW works:** treat the family as **"LTO generation 0"** — reuse `ScsiWriteDirect` sensing, the
+ physical/logical EW mapping, and calibration unchanged, keyed by its own vendor/product/generation. This
+ needs a small generalization of the LTO-gated code paths (currently `IsLto`-gated) to an "EW-capable via
+ SPTD" predicate.
+- **If EW does not work** (likely for pure helical-scan or drives that reject SPTD): still provide a
+ **meaningful a-priori profile** so the estimate improves over the raw driver figure — measured margins for
+ these families if we can calibrate them, or conservative synthetic defaults otherwise.
+- **Deliverable either way:** an a-priori/reference profile per supported pre-LTO family, plus a documented
+ determination of which families can and cannot participate in EW/estimation.
+
+---
From 0c1a63b6b369951062faf00fbb7da1e1858528a8 Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Tue, 18 Aug 2026 02:07:28 +0200
Subject: [PATCH 24/37] Add InspectMedia() verb to TapeCalibrator, along with
its unit tests.
---
TapeLibNET.Tests/CalibrationResumeTests.cs | 126 ++++++++++++++++++++-
TapeLibNET/TapeCalibrationCheckpoint.cs | 63 +++++++++++
TapeLibNET/TapeCalibrator.cs | 103 +++++++++++++----
docs/Design-RemainingAndEw.md | 60 ++++++++++
4 files changed, 326 insertions(+), 26 deletions(-)
diff --git a/TapeLibNET.Tests/CalibrationResumeTests.cs b/TapeLibNET.Tests/CalibrationResumeTests.cs
index 02c37f0..a8572d7 100644
--- a/TapeLibNET.Tests/CalibrationResumeTests.cs
+++ b/TapeLibNET.Tests/CalibrationResumeTests.cs
@@ -447,6 +447,130 @@ public void Recalibrate_AfterDriveBehaviorChange_ReportsLargeEwShift()
Assert.True(Math.Abs(delta.EwShiftFraction) > 0.10,
$"EW shift {delta.EwShiftFraction:P1} should be large after a drive-behavior change");
}
-
+
+ #endregion
+
+ #region *** Media inspection (read-only) ***
+
+ [Fact]
+ public void InspectMedia_AfterCompleteRun_ReportsResumableAndComplete()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ ITapeCalibration? cal = new TapeCalibrator(drive) { Options = FastOptions() }.Run();
+ Assert.NotNull(cal);
+
+ TapeCalibrationMediaInfo? info = new TapeCalibrator(drive) { Options = FastOptions() }.InspectMedia();
+
+ Assert.NotNull(info);
+ Assert.True(info!.IsResumable, "a completed run leaves a resumable trail");
+ Assert.True(info.AppearsComplete, "a run that reached the tail should read as complete");
+ Assert.Equal(drive.DriveProfileKey, info.ProfileKey);
+ Assert.NotEqual(Guid.Empty, info.RunId);
+ Assert.True(info.CheckpointedBytes > 0);
+ Assert.True(info.CheckpointIndex >= 0);
+ // Checkpoints are BODY-ONLY (they stop just before the tail), so a complete run reads ≈ 0.95.
+ Assert.InRange(info.ProgressFraction, 0.5, 1.0);
+ }
+
+ [Fact]
+ public void InspectMedia_AfterAbortedRun_ReportsResumableButNotComplete()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ var run = new TapeCalibrator(drive) { Options = FastOptions() };
+ Assert.Null(run.Run(new AbortAfterBytes(run, Capacity / 2))); // interrupted ~halfway
+
+ TapeCalibrationMediaInfo? info = new TapeCalibrator(drive) { Options = FastOptions() }.InspectMedia();
+
+ Assert.NotNull(info);
+ Assert.True(info!.IsResumable, "an aborted run past its first checkpoint is resumable");
+ Assert.False(info.AppearsComplete, "a mid-body interruption should not read as complete");
+ Assert.Equal(drive.DriveProfileKey, info.ProfileKey);
+ Assert.InRange(info.ProgressFraction, 0.10, 0.90); // stopped mid-body, well short of the tail
+ }
+
+ [Fact]
+ public void InspectMedia_OnBlankCartridge_ReturnsNull()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ // No run performed ⇒ no header at BOM ⇒ nothing to inspect.
+ Assert.Null(new TapeCalibrator(drive) { Options = FastOptions() }.InspectMedia());
+ }
+
+ [Fact]
+ public void InspectMedia_OnForeignCartridgeWithRegularData_ReturnsNull()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ // Ordinary filemark-delimited data, but NO calibration header at BOM — a mixed-up cartridge.
+ Assert.True(drive.MoveToPartition(MediaPartition.Content));
+ Assert.True(drive.Rewind());
+ Assert.True(drive.SetBlockSize(drive.MaximumBlockSize));
+
+ int blk = (int)drive.BlockSize;
+ var data = new byte[blk];
+ new Random(123).NextBytes(data);
+ for (int seg = 0; seg < 4; seg++)
+ {
+ Assert.Equal(blk, drive.WriteDirect(data, 0, blk));
+ Assert.True(drive.WriteFilemark(1));
+ }
+
+ Assert.Null(new TapeCalibrator(drive) { Options = FastOptions() }.InspectMedia());
+ }
+
+ [Fact]
+ public void InspectMedia_IsNonDestructive_ResumeStillSucceeds()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ var run = new TapeCalibrator(drive) { Options = FastOptions() };
+ Assert.Null(run.Run(new AbortAfterBytes(run, Capacity / 2)));
+
+ // Inspect TWICE — the read must be idempotent and must not consume the trail.
+ var inspector = new TapeCalibrator(drive) { Options = FastOptions() };
+ TapeCalibrationMediaInfo? info1 = inspector.InspectMedia();
+ TapeCalibrationMediaInfo? info2 = inspector.InspectMedia();
+ Assert.NotNull(info1);
+ Assert.NotNull(info2);
+ Assert.Equal(info1!.RunId, info2!.RunId); // same run identified both times
+ Assert.Equal(info1.CheckpointedBytes, info2.CheckpointedBytes);
+
+ // The crucial contract: inspection wrote nothing, so a real Resume still completes.
+ ITapeCalibration? resumed = new TapeCalibrator(drive) { Options = FastOptions() }.Resume();
+ Assert.NotNull(resumed);
+ Assert.InRange(resumed!.CapacityActual, (long)(Capacity * 0.98), Capacity);
+ AssertCurveWellFormed(resumed);
+
+ // After a completed resume the SAME run is still identifiable and now reads as complete.
+ TapeCalibrationMediaInfo? after = new TapeCalibrator(drive) { Options = FastOptions() }.InspectMedia();
+ Assert.NotNull(after);
+ Assert.Equal(info1.RunId, after!.RunId); // RunId preserved across resume
+ Assert.True(after.AppearsComplete);
+ }
+
+ [Fact]
+ public void InspectMedia_DoesNotDisturbLoadedCalibrationsOrReserve()
+ {
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ // Leave a resumable trail so InspectMedia has a header to read.
+ Assert.NotNull(new TapeCalibrator(drive) { Options = FastOptions() }.Run());
+
+ // A pre-existing reserve + loaded calibration the read-only inspect must NOT touch (it uses no
+ // RunGuard, unlike Run/Resume/Recalibrate).
+ var preloaded = TapeCalibration.Apriori(drive.DriveProfileKey, Capacity);
+ Assert.True(drive.AddCalibration(preloaded));
+ const long reserve = 2L * 1024 * 1024;
+ Assert.True(drive.SetEarlyWarning(reserve));
+
+ Assert.NotNull(new TapeCalibrator(drive) { Options = FastOptions() }.InspectMedia());
+
+ Assert.Equal(reserve, drive.EarlyWarning);
+ Assert.Contains(preloaded, drive.Calibrations);
+ }
+
#endregion
}
diff --git a/TapeLibNET/TapeCalibrationCheckpoint.cs b/TapeLibNET/TapeCalibrationCheckpoint.cs
index 3c9d1a1..c663223 100644
--- a/TapeLibNET/TapeCalibrationCheckpoint.cs
+++ b/TapeLibNET/TapeCalibrationCheckpoint.cs
@@ -278,3 +278,66 @@ public double CapacityShiftFraction
public double PhantomShiftFraction
=> OldPhantomFreeAtEom > 0 ? (double)(NewPhantomFreeAtEom - OldPhantomFreeAtEom) / OldPhantomFreeAtEom : 0.0;
}
+
+///
+/// Read-only snapshot of what a cartridge holds, produced by
+/// WITHOUT writing anything. Lets a UI (or service) decide whether to offer
+/// / and show run identity + progress, before committing to a
+/// destructive operation. Present ⇒ a valid calibration header was found; ⇒ a
+/// CRC-valid checkpoint of that run also exists.
+///
+public sealed record TapeCalibrationMediaInfo(
+ TapeCalibrationRunHeader Header,
+ TapeCalibrationCheckpoint? LastCheckpoint)
+{
+ /// The run's unique id (from the header).
+ public Guid RunId => Header.RunId;
+
+ /// The drive+media profile key the run was recorded against.
+ public string ProfileKey => Header.ProfileKey;
+
+ /// Driver-reported capacity at BOM captured at the start of the run.
+ public long CapacityReportedAtBom => Header.CapacityReportedAtBom;
+
+ /// When the run started (UTC).
+ public DateTime StartedUtc => Header.StartedUtc;
+
+ /// True when a CRC-valid checkpoint of this run exists — i.e.
+ /// / can proceed. False when the run died before its first
+ /// checkpoint (or every checkpoint is torn): the cartridge is inspectable but not resumable.
+ public bool IsResumable => LastCheckpoint is not null;
+
+ /// Bytes written as of the last good checkpoint (0 when none) — the resume restart point.
+ public long CheckpointedBytes => LastCheckpoint?.BytesWritten ?? 0L;
+
+ /// Index of the last good checkpoint, or -1 when none.
+ public int CheckpointIndex => LastCheckpoint?.Index ?? -1;
+
+ /// Whether the EW landmark was already captured by the last checkpoint.
+ public bool EarlyWarningCaptured => LastCheckpoint?.EarlyWarning is not null;
+
+ ///
+ /// Progress hint in 0..1 = last-checkpoint bytes / BOM-reported capacity. Because checkpoints are
+ /// BODY-ONLY (they stop just before the tail), a COMPLETED run reads ≈ (1 − TailCapacityFraction)
+ /// (~0.95), and an interrupted one reads proportionally less — a good "how far did it get" figure.
+ ///
+ public double ProgressFraction =>
+ Header.CapacityReportedAtBom > 0
+ ? Math.Clamp((double)CheckpointedBytes / Header.CapacityReportedAtBom, 0.0, 1.0)
+ : 0.0;
+
+ ///
+ /// Heuristic (no extra tape I/O): the last checkpoint is within one checkpoint-interval of the tail
+ /// start, so the run most likely REACHED the tail and COMPLETED — favor Recalibrate. Otherwise it was
+ /// interrupted mid-body — favor Resume. Fuzzy near the very end, by nature.
+ ///
+ public bool AppearsComplete
+ {
+ get
+ {
+ long cap = Math.Max(1L, Header.CapacityReportedAtBom);
+ return LastCheckpoint is not null
+ && CheckpointedBytes >= Header.Plan.TailStartBytes(cap) - Header.Plan.CheckpointInterval(cap);
+ }
+ }
+}
\ No newline at end of file
diff --git a/TapeLibNET/TapeCalibrator.cs b/TapeLibNET/TapeCalibrator.cs
index e866bed..726a551 100644
--- a/TapeLibNET/TapeCalibrator.cs
+++ b/TapeLibNET/TapeCalibrator.cs
@@ -245,32 +245,10 @@ private sealed class RunState
///
private TapeCalibration? ResumeCore(IProgress? progress)
{
- if (!Drive.IsMediaLoaded)
- {
- SetError(WIN32_ERROR.ERROR_NO_MEDIA_IN_DRIVE);
- LogErrorAsDebug("Resume: no media loaded");
- return null;
- }
-
- if (!PrepareDrive(out TapeCalibrationPlan _, out uint blockSize))
- return null;
-
- // --- Read the header block (File 0 at BOM) to recover RunId, plan, and BOM capacity. ---
- if (!Drive.Rewind())
- {
- SyncErrorFrom(Drive);
- LogErrorAsDebug("Resume: failed to rewind to header");
- return null;
- }
-
- var recordBuffer = new byte[blockSize];
- TapeCalibrationRunHeader? header = ReadRecord(recordBuffer);
+ // Position at BOM and read the run header (read-only; shared with InspectMedia).
+ TapeCalibrationRunHeader? header = ReadRunHeader(out uint blockSize, out byte[] recordBuffer);
if (header is null)
- {
- SetError(WIN32_ERROR.ERROR_INVALID_DATA);
- LogErrorAsDebug("Resume: no valid calibration header on this cartridge — not resumable");
- return null;
- }
+ return null; // no media / no valid header — error state already set
// Prefer the ORIGINAL plan (identical cadence/chunking); re-derive chunks if the drive now rounds
// the block size differently than when the run started.
@@ -330,6 +308,81 @@ private sealed class RunState
#endregion
+ #region *** Media inspection (read-only) ***
+
+ ///
+ /// Reads the on-tape run header (File 0 at BOM) and, when present, the last CRC-valid checkpoint,
+ /// WITHOUT writing anything — safe to call speculatively (e.g. from a UI on media load, to decide
+ /// whether to offer Resume / Recalibrate). Returns a describing
+ /// the run and its resumability, or (error state set, per convention) when no
+ /// valid calibration header is present on the loaded cartridge.
+ ///
+ /// Non-destructive: like the run verbs it positions on the content partition and sets the drive's block
+ /// size / compression via , but it never writes to tape, so the calibration
+ /// trail is preserved. Unlike the run verbs it does NOT neutralize the caller's loaded calibrations or
+ /// EW reserve (no RunGuard) — a pure read leaves that state untouched.
+ ///
+ ///
+ public TapeCalibrationMediaInfo? InspectMedia()
+ {
+ ResetError();
+
+ TapeCalibrationRunHeader? header = ReadRunHeader(out _, out byte[] recordBuffer);
+ if (header is null)
+ return null; // no media / no valid header — error state already set
+
+ // Locate the last CRC-valid checkpoint of this run (read-only; null ⇒ header-only / all torn).
+ TapeCalibrationCheckpoint? last = FindLastCheckpoint(header.RunId, recordBuffer, out _);
+
+ ResetError();
+ return new TapeCalibrationMediaInfo(header, last);
+ }
+
+ ///
+ /// Positions at BOM and reads + CRC-parses the run header (File 0). Shared by
+ /// and . On success the tape sits just past the header block and
+ /// is sized to one calibration block for reuse by
+ /// . Returns (error state set) when the drive
+ /// cannot be prepared or no valid header is present. WRITES NOTHING.
+ ///
+ private TapeCalibrationRunHeader? ReadRunHeader(out uint blockSize, out byte[] recordBuffer)
+ {
+ blockSize = 0;
+ recordBuffer = [];
+
+ if (!Drive.IsMediaLoaded)
+ {
+ SetError(WIN32_ERROR.ERROR_NO_MEDIA_IN_DRIVE);
+ LogErrorAsDebug("Calibration inspect: no media loaded");
+ return null;
+ }
+
+ if (!PrepareDrive(out _, out blockSize))
+ return null;
+
+ // Rewind to and read the header block (File 0 at BOM). PrepareDrive already positions at BOM;
+ // the explicit rewind is belt-and-suspenders and matches the original resume path.
+ if (!Drive.Rewind())
+ {
+ SyncErrorFrom(Drive);
+ LogErrorAsDebug("Calibration inspect: failed to rewind to header");
+ return null;
+ }
+
+ recordBuffer = new byte[blockSize];
+ TapeCalibrationRunHeader? header = ReadRecord(recordBuffer);
+ if (header is null)
+ {
+ SetError(WIN32_ERROR.ERROR_INVALID_DATA);
+ LogErrorAsDebug("Calibration inspect: no valid calibration header on this cartridge");
+ return null;
+ }
+
+ return header;
+ }
+
+ #endregion
+
#region *** Shared write loop ***
///
diff --git a/docs/Design-RemainingAndEw.md b/docs/Design-RemainingAndEw.md
index 8230e05..d16ed93 100644
--- a/docs/Design-RemainingAndEw.md
+++ b/docs/Design-RemainingAndEw.md
@@ -1171,6 +1171,66 @@ already survives a reboot, and the fastest reposition is EOD→back-space regard
(old/new EW-distance, capacity, phantom + signed fractions); it does **not** judge the result, and it does
**not** perform drive-profile matching — both are the caller's/service's concern (Part 3's layering).
+### 6.4.1 Read-only media inspection — `InspectMedia()` [DONE]
+
+Before a UI offers **Resume** or **Recalibrate**, it must know what the loaded cartridge actually holds —
+*without* committing to a destructive operation. `TapeCalibrator.InspectMedia()` provides exactly that: a
+**pure read** of the on-tape trail that writes nothing and never invalidates the medium.
+
+```csharp
+public TapeCalibrationMediaInfo? InspectMedia();
+```
+
+- **What it reads.** It positions on the content partition, sets the calibration block size / compression
+ (via the shared `PrepareDrive` — these change drive parameters but write no tape), rewinds, reads the
+ **run header** (File 0 at BOM), then walks back from EOD to locate the **last CRC-valid checkpoint** of
+ that run (the same `FindLastCheckpoint` the resume path uses). Returns `null` (with the error state set,
+ per convention — `ERROR_INVALID_DATA` / `ERROR_NO_MEDIA_IN_DRIVE`) when no valid calibration header is
+ present, so a blank or foreign cartridge is rejected in O(1) at the header read, before any backward walk.
+
+- **Writes nothing, disturbs nothing.** Unlike `Run` / `Resume` / `Recalibrate`, `InspectMedia` does **not**
+ wrap itself in `RunGuard` — a read has no reason to strip and restore the caller's loaded calibrations or
+ EW reserve, so that state is left untouched. It is idempotent: inspecting twice yields the same result,
+ and a subsequent `Resume` still succeeds because the trail was never consumed.
+
+- **Shared header read.** The header-read-and-parse block previously inlined in `ResumeCore` is factored
+ into a private `ReadRunHeader(out blockSize, out recordBuffer)` used by both `InspectMedia` and
+ `ResumeCore`, so the two can never drift.
+
+#### `TapeCalibrationMediaInfo`
+
+A read-only snapshot returned by `InspectMedia`, carrying the header plus the last good checkpoint and a few
+derived, UI-facing convenience values:
+
+| Member | Meaning |
+|---|---|
+| `Header` | The parsed `TapeCalibrationRunHeader` (RunId, ProfileKey, BOM capacity, plan, start time). |
+| `LastCheckpoint` | The last CRC-valid `TapeCalibrationCheckpoint`, or `null` when the run died before its first checkpoint (or every checkpoint is torn). |
+| `RunId` / `ProfileKey` / `CapacityReportedAtBom` / `StartedUtc` | Convenience passthroughs from `Header`. |
+| `IsResumable` | `true` when a CRC-valid checkpoint exists — i.e. `Resume` / `Recalibrate` can proceed. |
+| `CheckpointedBytes` / `CheckpointIndex` | Bytes written / index at the last good checkpoint (0 / −1 when none) — the resume restart point. |
+| `EarlyWarningCaptured` | Whether the EW landmark was already captured by the last checkpoint. |
+| `ProgressFraction` | `CheckpointedBytes / CapacityReportedAtBom`, clamped 0..1 — a "how far did it get" hint. |
+| `AppearsComplete` | Heuristic (no extra tape I/O): the last checkpoint is within one checkpoint-interval of the tail start, so the run most likely reached the tail and completed. |
+
+Because checkpoints are **body-only** (they stop just before the tail), a *completed* run's last checkpoint
+sits near `(1 − TailCapacityFraction)` (~0.95) of capacity, while an *interrupted* one sits wherever it
+stopped. This lets a UI distinguish the two states — and pick a sensible default (Recalibrate for an
+apparently-complete cartridge, Resume for an interrupted one) — from a single read, with **no** EOD-seek or
+byte-position measurement. `AppearsComplete` is deliberately documented as a heuristic (fuzzy right at the
+tail boundary); if a future workflow needs certainty, the clean upgrade is a tiny "final" trailer record
+written at EOM that `InspectMedia` would read deterministically — deferred until a caller requires it.
+
+The verb is **advisory only**: like `Recalibrate`, it makes no policy decision and performs no drive-profile
+matching. The service / UI layer decides what to offer based on `IsResumable`, `AppearsComplete`, and its
+own match of `ProfileKey` against the current drive.
+
+> **Files touched:** `TapeCalibrator.cs` (new `InspectMedia` + shared `ReadRunHeader`; `ResumeCore`
+> refactored onto it); `TapeCalibrationCheckpoint.cs` (new `TapeCalibrationMediaInfo` record).
+> Covered by `CalibrationResumeTests` (complete/aborted/blank/foreign cases; the non-destructive
+> "inspect-twice-then-resume-still-succeeds" contract; and the "does not disturb loaded calibrations /
+> reserve" guarantee).
+
### 6.5 A genuine `VirtualTapeMedia` bug, surfaced by resume [DONE]
Resume repositions **in front of the last filemark on a full tape** and overwrites. `WriteBlocks` and
From bb9011ad80c613bf26aefc36f6e1a4c99d7a2e35 Mon Sep 17 00:00:00 2001
From: Alex K
Date: Tue, 18 Aug 2026 03:38:58 +0200
Subject: [PATCH 25/37] Implement Recalibration / Resume Calibration UI for
TapeWinNET.
---
TapeLibNET/Services/ServiceOperationResult.cs | 44 +++-
.../Services/TapeServiceBase.Calibrate.cs | 102 +++++++++
TapeWinNET/CalibrateWindow.xaml | 164 +++++++++----
TapeWinNET/CalibrateWindow.xaml.cs | 15 +-
TapeWinNET/CalibrationProfilesWindow.xaml | 58 +----
TapeWinNET/CalibrationWindow.xaml | 59 +----
TapeWinNET/CalibrationWindow.xaml.cs | 13 +-
.../Controls/CalibrationResultView.xaml | 145 ++++++++++++
.../Controls/CalibrationResultView.xaml.cs | 18 ++
.../CalibrationProfilesViewModel.cs | 43 +---
.../ViewModels/CalibrationResultViewModel.cs | 168 ++++++++++++++
.../CalibrationResultViewModelBase.cs | 111 +++++++++
.../ViewModels/CalibrationRunViewModel.cs | 203 +++++++++++++++++
TapeWinNET/ViewModels/CalibrationViewModel.cs | 215 ------------------
.../ViewModels/MainViewModel.Calibration.cs | 22 +-
docs/Design-RemainingAndEw.md | 30 ++-
16 files changed, 991 insertions(+), 419 deletions(-)
create mode 100644 TapeWinNET/Controls/CalibrationResultView.xaml
create mode 100644 TapeWinNET/Controls/CalibrationResultView.xaml.cs
create mode 100644 TapeWinNET/ViewModels/CalibrationResultViewModel.cs
create mode 100644 TapeWinNET/ViewModels/CalibrationResultViewModelBase.cs
create mode 100644 TapeWinNET/ViewModels/CalibrationRunViewModel.cs
delete mode 100644 TapeWinNET/ViewModels/CalibrationViewModel.cs
diff --git a/TapeLibNET/Services/ServiceOperationResult.cs b/TapeLibNET/Services/ServiceOperationResult.cs
index 1d789dd..3c26b94 100644
--- a/TapeLibNET/Services/ServiceOperationResult.cs
+++ b/TapeLibNET/Services/ServiceOperationResult.cs
@@ -189,7 +189,49 @@ public sealed record CalibrateResult : FileOperationResult
public override bool IsFullSuccess => base.IsFullSuccess && Calibration is not null;
}
-// ── List ─────────────────────────────────────────────────────────────────────
+///
+/// Result of a non-destructive probe, enriched with the
+/// service-layer policy (store lookup) that the calibrator itself stays free of. Lets a UI decide
+/// which mode to recommend WITHOUT gating anything — inspection is always an optional convenience.
+///
+public sealed record InspectCalibrationMediaResult : ServiceOperationResult
+{
+ /// True when a valid calibration run header was found on the loaded cartridge.
+ public bool HasRunHeader { get; init; }
+
+ /// The drive+media profile key recorded in the header, or empty when no header was found.
+ public string ProfileKey { get; init; } = string.Empty;
+
+ /// When the inspected run started (UTC), or when no header was found.
+ public DateTime StartedUtc { get; init; }
+
+ /// Driver-reported capacity at BOM, captured at the start of the inspected run.
+ public long CapacityReportedAtBom { get; init; }
+
+ /// True when a CRC-valid checkpoint of the run exists — i.e. Resume can proceed.
+ public bool HasCheckpoint { get; init; }
+
+ /// Bytes written as of the last good checkpoint.
+ public long BytesWritten { get; init; }
+
+ /// Progress hint in 0..1 — see .
+ public double ProgressFraction { get; init; }
+
+ /// True when the header's profile key matches the currently loaded drive+media.
+ public bool MatchesCurrentDrive { get; init; }
+
+ /// True when a calibration profile for this run's key is already in the shared store —
+ /// the strongest signal that the run reached the tail and completed.
+ public bool HasStoredCalibration { get; init; }
+
+ /// The mode the service recommends offering, or when no header was found.
+ public CalibrationMode? RecommendedMode { get; init; }
+
+ /// Ready-to-display summary of the inspection, for the UI's inspect-result pane.
+ public string Summary { get; init; } = string.Empty;
+}
+
+// ── List ──────
///
/// Summary result of a list / contents-display operation.
diff --git a/TapeLibNET/Services/TapeServiceBase.Calibrate.cs b/TapeLibNET/Services/TapeServiceBase.Calibrate.cs
index 4013b7f..f7a1cc1 100644
--- a/TapeLibNET/Services/TapeServiceBase.Calibrate.cs
+++ b/TapeLibNET/Services/TapeServiceBase.Calibrate.cs
@@ -354,6 +354,108 @@ public bool AddCalibration(ITapeCalibration calibration)
return _drive.AddCalibration(calibration);
}
+ // ── Media inspection (read-only, optional convenience) ──────────────────────────────────────
+ ///
+ /// Non-destructively probes the loaded cartridge for an existing calibration trail, combining the
+ /// on-tape header/checkpoint () with a
+ /// lookup by profile key to recommend a .
+ /// This is a pure convenience for the UI — it never gates New/Resume/Recalibrate, which all remain
+ /// available regardless of the result.
+ ///
+ public Task ExecuteInspectCalibrationMediaAsync()
+ {
+ _host.OnServiceStateChanged(ServiceStateChange.OperationStarted);
+
+ return Task.Run(() =>
+ {
+ try
+ {
+ LogInfo("Starting media inspection for recalibration");
+
+ if (_drive is null || !_drive.IsMediaLoaded)
+ {
+ LastError = "Media not loaded";
+ return new InspectCalibrationMediaResult
+ {
+ Success = false,
+ Outcome = ServiceReportLevel.Error,
+ Message = LastError,
+ };
+ }
+
+ var calibrator = new TapeCalibrator(_drive);
+ TapeCalibrationMediaInfo? info = calibrator.InspectMedia();
+
+ if (info is null)
+ {
+ LogInfo("Media inspection: no calibration trail found on this cartridge");
+ return new InspectCalibrationMediaResult
+ {
+ Success = true,
+ Outcome = ServiceReportLevel.Info,
+ HasRunHeader = false,
+ Summary = "No calibration trail found on this cartridge — a New run is required.",
+ };
+ }
+
+ bool matchesDrive = string.Equals(info.ProfileKey, _drive.DriveProfileKey, StringComparison.Ordinal);
+ bool hasStored = CalibrationStore.Exists(info.ProfileKey);
+
+ CalibrationMode recommended = hasStored
+ ? CalibrationMode.Recalibrate
+ : info.IsResumable
+ ? CalibrationMode.Resume
+ : CalibrationMode.New;
+
+ string summary = hasStored
+ ? $"A complete, stored calibration exists for this cartridge (started {info.StartedUtc:u}) — Recalibrate recommended."
+ : info.IsResumable
+ ? $"An interrupted run was found ({info.ProgressFraction:P0} written, started {info.StartedUtc:u}) — Resume recommended."
+ : "A calibration header was found, but no valid checkpoint — the run cannot be resumed.";
+
+ if (!matchesDrive)
+ summary += " Note: this trail belongs to a different drive/media profile.";
+
+ LogInfo("Media inspection:");
+ LogInfoSub($"Profile key: >{info.ProfileKey}<");
+ LogInfoSub($"Started: {info.StartedUtc:u}, resumable: {info.IsResumable}, stored: {hasStored}");
+
+ return new InspectCalibrationMediaResult
+ {
+ Success = true,
+ Outcome = ServiceReportLevel.Completed,
+ HasRunHeader = true,
+ ProfileKey = info.ProfileKey,
+ StartedUtc = info.StartedUtc,
+ CapacityReportedAtBom = info.CapacityReportedAtBom,
+ HasCheckpoint = info.IsResumable,
+ BytesWritten = info.CheckpointedBytes,
+ ProgressFraction = info.ProgressFraction,
+ MatchesCurrentDrive = matchesDrive,
+ HasStoredCalibration = hasStored,
+ RecommendedMode = recommended,
+ Summary = summary,
+ };
+ }
+ catch (Exception ex)
+ {
+ LastError = ex.Message;
+ LogErr($"Media inspection failed: {ex.Message}");
+ return new InspectCalibrationMediaResult
+ {
+ Success = false,
+ Outcome = ServiceReportLevel.Error,
+ Message = ex.Message,
+ Error = ex,
+ };
+ }
+ finally
+ {
+ _host.OnServiceStateChanged(ServiceStateChange.OperationEnded);
+ }
+ });
+ }
+
// ── Calibration autoload ──────────────────────────────────────────────────
private TapeCalibrationStore? _calibrationStore;
diff --git a/TapeWinNET/CalibrateWindow.xaml b/TapeWinNET/CalibrateWindow.xaml
index 806dabc..4050854 100644
--- a/TapeWinNET/CalibrateWindow.xaml
+++ b/TapeWinNET/CalibrateWindow.xaml
@@ -7,7 +7,8 @@
xmlns:converters="clr-namespace:TapeWinNET.Converters"
xmlns:controls="clr-namespace:TapeWinNET.Controls"
xmlns:help="clr-namespace:TapeWinNET.Help"
- d:DataContext="{d:DesignInstance Type=vm:CalibrationViewModel}"
+ xmlns:services="clr-namespace:TapeLibNET.Services;assembly=tapelib"
+ d:DataContext="{d:DesignInstance Type=vm:CalibrationRunViewModel}"
mc:Ignorable="d"
Title="Calibrate Media"
Height="440" Width="520"
@@ -16,6 +17,10 @@
ShowInTaskbar="False"
PreviewKeyDown="Window_PreviewKeyDown">
+
+
+
+
@@ -34,7 +39,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ help:HelpControlNameAttachedProperty.ControlName="Confirm calibration">
+
+
-
+ InspectionResultBorder.BringIntoView());
+ }
+ }
+
private void HelpButton_Click(object sender, RoutedEventArgs e)
=> _help.ToggleHelpPane();
diff --git a/TapeWinNET/CalibrationProfilesWindow.xaml b/TapeWinNET/CalibrationProfilesWindow.xaml
index 066ddbc..6eddb62 100644
--- a/TapeWinNET/CalibrationProfilesWindow.xaml
+++ b/TapeWinNET/CalibrationProfilesWindow.xaml
@@ -24,7 +24,6 @@
-
@@ -51,59 +50,10 @@
DisplayMemberPath="ProfileKey"/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ {
+ DialogResult = true;
+ Close();
+ };
+
_help = new DialogHelpPaneController(
this, this, HelpPaneColumn, HelpPaneSplitter, HelpPaneControl,
defaultTopicId: "dialog.calibration-result", helpButton: HelpButton);
}
+ /// True when the user requested a follow-up full calibration via the result window's
+ /// "Run Full Calibration..." button (only offered when a recalibration was found unreliable).
+ public bool FullCalibrationRequested => DataContext is CalibrationResultViewModel { FullCalibrationRequested: true };
+
+
private void HelpButton_Click(object sender, RoutedEventArgs e)
=> _help.ToggleHelpPane();
diff --git a/TapeWinNET/Controls/CalibrationResultView.xaml b/TapeWinNET/Controls/CalibrationResultView.xaml
new file mode 100644
index 0000000..5e80f2d
--- /dev/null
+++ b/TapeWinNET/Controls/CalibrationResultView.xaml
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TapeWinNET/Controls/CalibrationResultView.xaml.cs b/TapeWinNET/Controls/CalibrationResultView.xaml.cs
new file mode 100644
index 0000000..a097e61
--- /dev/null
+++ b/TapeWinNET/Controls/CalibrationResultView.xaml.cs
@@ -0,0 +1,18 @@
+using System.Windows.Controls;
+
+namespace TapeWinNET.Controls;
+
+///
+/// Shared calibration-result display surface: verdict banner, measured-result figures (with an
+/// optional before/after delta for recalibration), and the reported→actual curve. Inherits its
+/// DataContext from the host window, so both and
+/// just drop it in against a
+/// CalibrationResultViewModelBase-derived view model.
+///
+public partial class CalibrationResultView : UserControl
+{
+ public CalibrationResultView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs b/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs
index ad90fbc..2ae76c7 100644
--- a/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs
+++ b/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs
@@ -2,8 +2,6 @@
using System.Windows;
using System.Windows.Input;
-using Windows.Win32.System.SystemServices; // Helpers.BytesToStringLong
-
using TapeLibNET;
using TapeWinNET.Services;
@@ -14,12 +12,11 @@ namespace TapeWinNET.ViewModels;
/// calibration profile previously persisted to , lets the
/// user inspect one, apply it to the currently loaded media, or remove it from the store.
///
-public sealed class CalibrationProfilesViewModel : ViewModelBase
+public sealed class CalibrationProfilesViewModel : CalibrationResultViewModelBase
{
private readonly TapeService _tapeService;
private readonly Func _isBusy;
private ITapeCalibration? _selectedProfile;
- private string _statusMessage = string.Empty;
public CalibrationProfilesViewModel(TapeService tapeService, Func isBusy)
{
@@ -36,6 +33,9 @@ public CalibrationProfilesViewModel(TapeService tapeService, Func isBusy)
public ObservableCollection Profiles { get; } = [];
+ ///
+ public override ITapeCalibration? Calibration => SelectedProfile;
+
public ITapeCalibration? SelectedProfile
{
get => _selectedProfile;
@@ -45,12 +45,7 @@ public ITapeCalibration? SelectedProfile
return;
OnPropertyChanged(nameof(HasSelection));
- OnPropertyChanged(nameof(ReportedCapacityAtBomDisplay));
- OnPropertyChanged(nameof(PhantomFreeAtEomDisplay));
- OnPropertyChanged(nameof(CapacityActualDisplay));
- OnPropertyChanged(nameof(EarlyWarningDisplay));
- OnPropertyChanged(nameof(EwToEomDistanceDisplay));
- OnPropertyChanged(nameof(CurvePointCountDisplay));
+ RaiseResultPropertiesChanged();
StatusMessage = string.Empty;
CommandManager.InvalidateRequerySuggested();
}
@@ -58,34 +53,6 @@ public ITapeCalibration? SelectedProfile
public bool HasSelection => SelectedProfile is not null;
- public string ReportedCapacityAtBomDisplay =>
- SelectedProfile is not null ? Helpers.BytesToStringLong(SelectedProfile.ReportedCapacityAtBom) : "—";
-
- public string PhantomFreeAtEomDisplay =>
- SelectedProfile is not null ? Helpers.BytesToStringLong(SelectedProfile.PhantomFreeAtEom) : "—";
-
- public string CapacityActualDisplay =>
- SelectedProfile is not null ? Helpers.BytesToStringLong(SelectedProfile.CapacityActual) : "—";
-
- public string EarlyWarningDisplay =>
- SelectedProfile?.EarlyWarning is { } ew
- ? $"{Helpers.BytesToStringLong(ew.ActualRemaining)} remaining (reported {Helpers.BytesToStringLong(ew.ReportedRemaining)})"
- : "Not observed";
-
- public string EwToEomDistanceDisplay =>
- SelectedProfile is not null && SelectedProfile.EwToEomDistance > 0
- ? Helpers.BytesToStringLong(SelectedProfile.EwToEomDistance)
- : "—";
-
- public string CurvePointCountDisplay =>
- SelectedProfile is not null ? SelectedProfile.Curve.Count.ToString("N0") : "0";
-
- public string StatusMessage
- {
- get => _statusMessage;
- private set => SetProperty(ref _statusMessage, value);
- }
-
#endregion
#region Commands
diff --git a/TapeWinNET/ViewModels/CalibrationResultViewModel.cs b/TapeWinNET/ViewModels/CalibrationResultViewModel.cs
new file mode 100644
index 0000000..e974840
--- /dev/null
+++ b/TapeWinNET/ViewModels/CalibrationResultViewModel.cs
@@ -0,0 +1,168 @@
+using System.Windows;
+using System.Windows.Input;
+
+using TapeLibNET;
+using TapeLibNET.Services;
+using TapeWinNET.Services;
+
+namespace TapeWinNET.ViewModels;
+
+///
+/// ViewModel for the calibration result dialog (), backed by a
+/// completed . Owns Save/Apply, the recalibration verdict banner, and the
+/// user-driven "run a full calibration after all" follow-up (via ).
+///
+public sealed class CalibrationResultViewModel : CalibrationResultViewModelBase
+{
+ private readonly TapeService _tapeService;
+ private readonly CalibrateResult _result;
+ private readonly Action? _onApplied;
+ private bool _isSaved;
+ private bool _isApplied;
+
+ public CalibrationResultViewModel(TapeService tapeService, CalibrateResult result, Action? onApplied = null)
+ {
+ _tapeService = tapeService;
+ _result = result;
+ _onApplied = onApplied;
+
+ SaveProfileCommand = new RelayCommand(_ => SaveProfile(), _ => Calibration is not null && !IsSaved);
+ ApplyProfileCommand = new RelayCommand(_ => ApplyProfile(), _ => Calibration is not null && !IsApplied);
+
+ if (_result is { RecalibrationVerdict: RecalibrationVerdict.FullRecalibrationAdvised })
+ RunFullCalibrationCommand = new RelayCommand(_ => RequestFullCalibration());
+ }
+
+ ///
+ public override ITapeCalibration? Calibration => _result.Calibration;
+
+ #region Result
+
+ public bool IsSaved
+ {
+ get => _isSaved;
+ private set
+ {
+ if (SetProperty(ref _isSaved, value))
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+
+ public bool IsApplied
+ {
+ get => _isApplied;
+ private set
+ {
+ if (SetProperty(ref _isApplied, value))
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+
+ #endregion
+
+ #region Verdict banner (Recalibrate only)
+
+ ///
+ public override bool HasVerdict => _result.RecalibrationVerdict is not null;
+
+ ///
+ public override WarningLevel VerdictLevel =>
+ _result.RecalibrationVerdict == RecalibrationVerdict.FullRecalibrationAdvised
+ ? WarningLevel.Warning
+ : WarningLevel.Info;
+
+ ///
+ public override string VerdictMessage =>
+ _result.RecalibrationVerdict switch
+ {
+ RecalibrationVerdict.Holds => "The existing calibration still holds — no full recalibration needed.",
+ RecalibrationVerdict.FullRecalibrationAdvised =>
+ "The drive's remaining-space behavior has shifted beyond tolerance — a full recalibration is advised.",
+ _ => string.Empty,
+ };
+
+ ///
+ public override bool HasRecalibrationDelta => _result.RecalibrationDelta is not null;
+
+ ///
+ public override string EwToEomDeltaDisplay =>
+ _result.RecalibrationDelta is { } d
+ ? $"{Windows.Win32.System.SystemServices.Helpers.BytesToStringLong(d.OldEwToEomDistance)} → " +
+ $"{Windows.Win32.System.SystemServices.Helpers.BytesToStringLong(d.NewEwToEomDistance)} ({d.EwShiftFraction:+0.0%;-0.0%})"
+ : string.Empty;
+
+ ///
+ public override string CapacityActualDeltaDisplay =>
+ _result.RecalibrationDelta is { } d
+ ? $"{Windows.Win32.System.SystemServices.Helpers.BytesToStringLong(d.OldCapacityActual)} → " +
+ $"{Windows.Win32.System.SystemServices.Helpers.BytesToStringLong(d.NewCapacityActual)} ({d.CapacityShiftFraction:+0.0%;-0.0%})"
+ : string.Empty;
+
+ ///
+ public override string PhantomFreeAtEomDeltaDisplay =>
+ _result.RecalibrationDelta is { } d
+ ? $"{Windows.Win32.System.SystemServices.Helpers.BytesToStringLong(d.OldPhantomFreeAtEom)} → " +
+ $"{Windows.Win32.System.SystemServices.Helpers.BytesToStringLong(d.NewPhantomFreeAtEom)} ({d.PhantomShiftFraction:+0.0%;-0.0%})"
+ : string.Empty;
+
+ ///
+ public override ICommand? RunFullCalibrationCommand { get; }
+
+ /// True when the user requested a follow-up full calibration via .
+ public bool FullCalibrationRequested { get; private set; }
+
+ /// Raised when the result window should close — either normally (Close) or to launch a
+ /// requested follow-up full calibration ().
+ public event EventHandler? CloseRequested;
+
+ private void RequestFullCalibration()
+ {
+ FullCalibrationRequested = true;
+ CloseRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ #endregion
+
+ #region Commands
+
+ public ICommand SaveProfileCommand { get; }
+ public ICommand ApplyProfileCommand { get; }
+
+ #endregion
+
+ #region Operations
+
+ private void SaveProfile()
+ {
+ if (Calibration is null)
+ return;
+
+ if (!App.Settings.Calibrations.Save(Calibration))
+ {
+ SimpleBox.Show(
+ $"Failed to save the calibration profile.\n\n{App.Settings.Calibrations.LastErrorMessage}",
+ "Save Calibration",
+ MessageBoxButton.OK,
+ SimpleBox.ImageFailed);
+ return;
+ }
+
+ IsSaved = true;
+ StatusMessage = "Calibration profile saved.";
+ }
+
+ private void ApplyProfile()
+ {
+ if (Calibration is null)
+ return;
+
+ bool matched = _tapeService.AddCalibration(Calibration);
+ IsApplied = true;
+ StatusMessage = matched
+ ? "Calibration profile applied to the current media."
+ : "Calibration profile loaded, but it does not match the current media.";
+ _onApplied?.Invoke();
+ }
+
+ #endregion
+}
diff --git a/TapeWinNET/ViewModels/CalibrationResultViewModelBase.cs b/TapeWinNET/ViewModels/CalibrationResultViewModelBase.cs
new file mode 100644
index 0000000..9d789e6
--- /dev/null
+++ b/TapeWinNET/ViewModels/CalibrationResultViewModelBase.cs
@@ -0,0 +1,111 @@
+using System.Windows.Input;
+
+using Windows.Win32.System.SystemServices; // Helpers.BytesToStringLong
+
+using TapeLibNET;
+using TapeLibNET.Services;
+
+namespace TapeWinNET.ViewModels;
+
+///
+/// Shared display surface for a calibration result: the measured figures (profile key, reported vs.
+/// actual capacity, phantom, EW landmark, curve point count) and — for
+/// — the verdict banner and before/after delta rows. Owns nothing operation-specific (no Save/Apply/Run),
+/// so both (a fresh run's result) and
+/// (a browsed, stored profile) derive from it and share the
+/// CalibrationResultView user control.
+///
+public abstract class CalibrationResultViewModelBase : ViewModelBase
+{
+ private string _statusMessage = string.Empty;
+
+ /// The calibration currently on display, or when none is selected.
+ public abstract ITapeCalibration? Calibration { get; }
+
+ #region Measured result display
+
+ public string ProfileKeyDisplay =>
+ Calibration is not null ? Calibration.ProfileKey : "(unknown)";
+
+ /// What the driver claimed was free on the virgin cartridge (quantity (4)).
+ public string ReportedCapacityAtBomDisplay =>
+ Calibration is not null ? Helpers.BytesToStringLong(Calibration.ReportedCapacityAtBom) : "—";
+
+ /// The headline result: phantom free space still claimed at hard EOM (quantity (5)).
+ public string PhantomFreeAtEomDisplay =>
+ Calibration is not null ? Helpers.BytesToStringLong(Calibration.PhantomFreeAtEom) : "—";
+
+ public string CapacityActualDisplay =>
+ Calibration is not null ? Helpers.BytesToStringLong(Calibration.CapacityActual) : "—";
+
+ public string EarlyWarningDisplay =>
+ Calibration?.EarlyWarning is { } ew
+ ? $"{Helpers.BytesToStringLong(ew.ActualRemaining)} remaining (reported {Helpers.BytesToStringLong(ew.ReportedRemaining)})"
+ : "Not observed";
+
+ public string EwToEomDistanceDisplay =>
+ Calibration is not null && Calibration.EwToEomDistance > 0
+ ? Helpers.BytesToStringLong(Calibration.EwToEomDistance)
+ : "—";
+
+ public string CurvePointCountDisplay =>
+ Calibration is not null ? Calibration.Curve.Count.ToString("N0") : "0";
+
+ public string StatusMessage
+ {
+ get => _statusMessage;
+ protected set => SetProperty(ref _statusMessage, value);
+ }
+
+ #endregion
+
+ #region Verdict banner (Recalibrate only — defaults to "no verdict")
+
+ /// True when a recalibration verdict is available to display.
+ public virtual bool HasVerdict => false;
+
+ public virtual WarningLevel VerdictLevel => WarningLevel.Info;
+
+ /// Alias for so the shared WarningPanelStyle border
+ /// (bound to WarningLevel) can drive the verdict banner's visibility/colors.
+ public WarningLevel WarningLevel => VerdictLevel;
+
+ public virtual string VerdictMessage => string.Empty;
+
+ /// True when before/after delta rows should be shown alongside the measured result.
+ public virtual bool HasRecalibrationDelta => false;
+
+ public virtual string EwToEomDeltaDisplay => string.Empty;
+
+ public virtual string CapacityActualDeltaDisplay => string.Empty;
+
+ public virtual string PhantomFreeAtEomDeltaDisplay => string.Empty;
+
+ /// Command to request a full recalibration after an advisory verdict, or
+ /// when this view has no such action (e.g. the profiles browser).
+ public virtual ICommand? RunFullCalibrationCommand => null;
+
+ #endregion
+
+ /// Raises property-changed for every display property derived from .
+ /// Call after the underlying calibration/result changes.
+ protected void RaiseResultPropertiesChanged()
+ {
+ OnPropertyChanged(nameof(Calibration));
+ OnPropertyChanged(nameof(ProfileKeyDisplay));
+ OnPropertyChanged(nameof(ReportedCapacityAtBomDisplay));
+ OnPropertyChanged(nameof(PhantomFreeAtEomDisplay));
+ OnPropertyChanged(nameof(CapacityActualDisplay));
+ OnPropertyChanged(nameof(EarlyWarningDisplay));
+ OnPropertyChanged(nameof(EwToEomDistanceDisplay));
+ OnPropertyChanged(nameof(CurvePointCountDisplay));
+ OnPropertyChanged(nameof(HasVerdict));
+ OnPropertyChanged(nameof(VerdictLevel));
+ OnPropertyChanged(nameof(WarningLevel));
+ OnPropertyChanged(nameof(VerdictMessage));
+ OnPropertyChanged(nameof(HasRecalibrationDelta));
+ OnPropertyChanged(nameof(EwToEomDeltaDisplay));
+ OnPropertyChanged(nameof(CapacityActualDeltaDisplay));
+ OnPropertyChanged(nameof(PhantomFreeAtEomDeltaDisplay));
+ }
+}
diff --git a/TapeWinNET/ViewModels/CalibrationRunViewModel.cs b/TapeWinNET/ViewModels/CalibrationRunViewModel.cs
new file mode 100644
index 0000000..a953e1a
--- /dev/null
+++ b/TapeWinNET/ViewModels/CalibrationRunViewModel.cs
@@ -0,0 +1,203 @@
+using System.Windows.Input;
+
+using Windows.Win32.System.SystemServices; // Helpers.BytesToStringLong
+
+using TapeLibNET;
+using TapeLibNET.Services;
+using TapeWinNET.Services;
+
+namespace TapeWinNET.ViewModels;
+
+///
+/// ViewModel for the calibration setup/confirmation dialog ().
+/// Owns mode selection (New / Resume / Recalibrate), the optional Inspect Media convenience probe, and
+/// the destructive calibration run itself. Result display/Save/Apply now live in
+/// , shown by a separate result window once the run completes.
+///
+public sealed class CalibrationRunViewModel : ViewModelBase
+{
+ private readonly TapeService _tapeService;
+ private readonly Action _onStart;
+ private readonly Action _onCancel;
+ private readonly CancellationTokenSource _abortCts = new();
+
+ private bool _isConfirmChecked;
+ private bool _ejectWhenDone;
+ private CalibrationMode _selectedMode = CalibrationMode.New;
+ private bool _isInspecting;
+ private string _inspectionSummary = string.Empty;
+ private WarningLevel _inspectionLevel = WarningLevel.Info;
+ private bool _hasInspectionResult;
+
+ public CalibrationRunViewModel(
+ TapeService tapeService,
+ Action onStart,
+ Action onCancel)
+ {
+ _tapeService = tapeService;
+ _onStart = onStart;
+ _onCancel = onCancel;
+
+ StartCommand = new RelayCommand(_ => _onStart(this), _ => IsConfirmChecked);
+ CancelCommand = new RelayCommand(_ => _onCancel());
+ InspectMediaCommand = new RelayCommand(async _ => await InspectMediaAsync(), _ => !_isInspecting);
+ }
+
+ #region Confirmation
+
+ public bool IsConfirmChecked
+ {
+ get => _isConfirmChecked;
+ set
+ {
+ if (SetProperty(ref _isConfirmChecked, value))
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+
+ /// Whether to eject the media once the calibration run completes.
+ public bool EjectWhenDone
+ {
+ get => _ejectWhenDone;
+ set => SetProperty(ref _ejectWhenDone, value);
+ }
+
+ public string Vendor => string.IsNullOrWhiteSpace(_tapeService.DeviceVendor) ? "Unknown" : _tapeService.DeviceVendor;
+ public string Product => string.IsNullOrWhiteSpace(_tapeService.DeviceProduct) ? "Unknown" : _tapeService.DeviceProduct;
+ public string Revision => string.IsNullOrWhiteSpace(_tapeService.DeviceRevision) ? "Unknown" : _tapeService.DeviceRevision;
+ public string ProfileKey => string.IsNullOrWhiteSpace(_tapeService.DriveProfileKey) ? "(unknown)" : _tapeService.DriveProfileKey;
+ public string CapacityDisplay => Helpers.BytesToStringLong(_tapeService.Capacity);
+ public string CapacityBucketDisplay => $"{TapeCalibration.CapacityBucket(_tapeService.Capacity)} bucket";
+ public static WarningLevel WarningLevel => WarningLevel.Warning;
+ public static string WarningMessage =>
+ "Calibration writes the scratch cartridge to end-of-media and destroys any existing content.\r\n" +
+ "Use only expendable media dedicated to calibration.";
+
+ #endregion
+
+ #region Mode selection
+
+ /// Which calibration operation to perform. All three remain available at all times —
+ /// Inspect Media is an optional convenience, never a gate.
+ public CalibrationMode SelectedMode
+ {
+ get => _selectedMode;
+ set
+ {
+ if (!SetProperty(ref _selectedMode, value))
+ return;
+
+ OnPropertyChanged(nameof(IsNewRunMode));
+ OnPropertyChanged(nameof(IsResumeMode));
+ OnPropertyChanged(nameof(IsRecalibrateMode));
+ OnPropertyChanged(nameof(IsInspectAvailable));
+ }
+ }
+
+ public bool IsNewRunMode
+ {
+ get => SelectedMode == CalibrationMode.New;
+ set { if (value) SelectedMode = CalibrationMode.New; }
+ }
+
+ public bool IsResumeMode
+ {
+ get => SelectedMode == CalibrationMode.Resume;
+ set { if (value) SelectedMode = CalibrationMode.Resume; }
+ }
+
+ public bool IsRecalibrateMode
+ {
+ get => SelectedMode == CalibrationMode.Recalibrate;
+ set { if (value) SelectedMode = CalibrationMode.Recalibrate; }
+ }
+
+ /// True for Resume/Recalibrate — the modes where inspecting the cartridge first is a
+ /// useful (but never required) convenience.
+ public bool IsInspectAvailable => !IsNewRunMode;
+
+ #endregion
+
+ #region Inspection (optional convenience — never gates a mode)
+
+ public string InspectionSummary
+ {
+ get => _inspectionSummary;
+ private set => SetProperty(ref _inspectionSummary, value);
+ }
+
+ public WarningLevel InspectionLevel
+ {
+ get => _inspectionLevel;
+ private set => SetProperty(ref _inspectionLevel, value);
+ }
+
+ public bool HasInspectionResult
+ {
+ get => _hasInspectionResult;
+ private set => SetProperty(ref _hasInspectionResult, value);
+ }
+
+ public ICommand InspectMediaCommand { get; }
+
+ private async Task InspectMediaAsync()
+ {
+ _isInspecting = true;
+ CommandManager.InvalidateRequerySuggested();
+
+ InspectionSummary = "Inspecting media, please wait...";
+ InspectionLevel = WarningLevel.Info;
+ HasInspectionResult = true;
+
+ try
+ {
+ InspectCalibrationMediaResult result = await _tapeService.ExecuteInspectCalibrationMediaAsync();
+
+ InspectionSummary = result.Success
+ ? result.Summary
+ : $"Inspection failed: {result.Message}";
+ InspectionLevel = result.Success
+ ? (result.HasRunHeader ? WarningLevel.Info : WarningLevel.Warning)
+ : WarningLevel.Failed;
+ HasInspectionResult = true;
+
+ if (result.RecommendedMode is { } recommended)
+ SelectedMode = recommended;
+ }
+ finally
+ {
+ _isInspecting = false;
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+
+ #endregion
+
+ #region Commands
+
+ public ICommand StartCommand { get; }
+ public ICommand CancelCommand { get; }
+
+ #endregion
+
+ #region Operations
+
+ public async Task RunAsync()
+ {
+ var result = await _tapeService.ExecuteCalibrateAsync(
+ new CalibrateRequest(
+ EjectWhenDone: EjectWhenDone,
+ Options: new TapeCalibrationOptions(),
+ Mode: SelectedMode)
+ {
+ Cancellation = _abortCts.Token,
+ OperationLabel = "Calibration",
+ });
+
+ return result;
+ }
+
+ public void RequestAbort() => _abortCts.Cancel();
+
+ #endregion
+}
diff --git a/TapeWinNET/ViewModels/CalibrationViewModel.cs b/TapeWinNET/ViewModels/CalibrationViewModel.cs
deleted file mode 100644
index c24d453..0000000
--- a/TapeWinNET/ViewModels/CalibrationViewModel.cs
+++ /dev/null
@@ -1,215 +0,0 @@
-using System.Windows;
-using System.Windows.Input;
-
-using Windows.Win32.System.SystemServices; // Helpers.BytesToStringLong
-
-using TapeLibNET;
-using TapeLibNET.Services;
-using TapeWinNET.Services;
-
-namespace TapeWinNET.ViewModels;
-
-///
-/// ViewModel for the calibration confirmation and result dialogs.
-/// Owns the destructive calibration run and the subsequent Save/Apply actions.
-///
-public sealed class CalibrationViewModel : ViewModelBase
-{
- private readonly TapeService _tapeService;
- private readonly Action _onStart;
- private readonly Action _onCancel;
- private readonly Action? _onApplied;
- private readonly CancellationTokenSource _abortCts = new();
-
- private bool _isConfirmChecked;
- private bool _ejectWhenDone;
- private bool _isSaved;
- private bool _isApplied;
- private string _statusMessage = string.Empty;
- private CalibrateResult? _result;
-
- public CalibrationViewModel(
- TapeService tapeService,
- Action onStart,
- Action onCancel,
- Action? onApplied = null)
- {
- _tapeService = tapeService;
- _onStart = onStart;
- _onCancel = onCancel;
- _onApplied = onApplied;
-
- StartCommand = new RelayCommand(_ => _onStart(this), _ => IsConfirmChecked);
- CancelCommand = new RelayCommand(_ => _onCancel());
- SaveProfileCommand = new RelayCommand(_ => SaveProfile(), _ => Result?.Calibration is not null && !IsSaved);
- ApplyProfileCommand = new RelayCommand(_ => ApplyProfile(), _ => Result?.Calibration is not null && !IsApplied);
- }
-
- #region Confirmation
-
- public bool IsConfirmChecked
- {
- get => _isConfirmChecked;
- set
- {
- if (SetProperty(ref _isConfirmChecked, value))
- CommandManager.InvalidateRequerySuggested();
- }
- }
-
- /// Whether to eject the media once the calibration run completes.
- public bool EjectWhenDone
- {
- get => _ejectWhenDone;
- set => SetProperty(ref _ejectWhenDone, value);
- }
-
- public string Vendor => string.IsNullOrWhiteSpace(_tapeService.DeviceVendor) ? "Unknown" : _tapeService.DeviceVendor;
- public string Product => string.IsNullOrWhiteSpace(_tapeService.DeviceProduct) ? "Unknown" : _tapeService.DeviceProduct;
- public string Revision => string.IsNullOrWhiteSpace(_tapeService.DeviceRevision) ? "Unknown" : _tapeService.DeviceRevision;
- public string ProfileKey => string.IsNullOrWhiteSpace(_tapeService.DriveProfileKey) ? "(unknown)" : _tapeService.DriveProfileKey;
- public string CapacityDisplay => Helpers.BytesToStringLong(_tapeService.Capacity);
- public string CapacityBucketDisplay => $"{TapeCalibration.CapacityBucket(_tapeService.Capacity)} bucket";
- public static WarningLevel WarningLevel => WarningLevel.Error;
- public static string WarningMessage =>
- "Calibration writes the scratch cartridge to end-of-media and destroys any existing content.\r\n" +
- "Use only expendable media dedicated to calibration.";
-
- #endregion
-
- #region Result
-
- public CalibrateResult? Result
- {
- get => _result;
- private set
- {
- if (!SetProperty(ref _result, value))
- return;
-
- OnPropertyChanged(nameof(Calibration));
- OnPropertyChanged(nameof(ReportedCapacityAtBomDisplay));
- OnPropertyChanged(nameof(PhantomFreeAtEomDisplay));
- OnPropertyChanged(nameof(CapacityActualDisplay));
- OnPropertyChanged(nameof(EarlyWarningDisplay));
- OnPropertyChanged(nameof(EwToEomDistanceDisplay));
- OnPropertyChanged(nameof(CurvePointCountDisplay));
- CommandManager.InvalidateRequerySuggested();
- }
- }
-
- public ITapeCalibration? Calibration => Result?.Calibration;
-
- /// What the driver claimed was free on the virgin cartridge (quantity (4)).
- public string ReportedCapacityAtBomDisplay =>
- Result is not null ? Helpers.BytesToStringLong(Result.ReportedCapacityAtBom) : "—";
-
- /// The headline result: phantom free space still claimed at hard EOM (quantity (5)).
- public string PhantomFreeAtEomDisplay =>
- Result is not null ? Helpers.BytesToStringLong(Result.PhantomFreeAtEom) : "—";
-
- public string CapacityActualDisplay =>
- Result is not null ? Helpers.BytesToStringLong(Result.CapacityActual) : "—";
-
- public string EarlyWarningDisplay =>
- Result?.EarlyWarning is { } ew
- ? $"{Helpers.BytesToStringLong(ew.ActualRemaining)} remaining (reported {Helpers.BytesToStringLong(ew.ReportedRemaining)})"
- : "Not observed";
-
- public string EwToEomDistanceDisplay =>
- Result is not null && Result.EwToEomDistance > 0
- ? Helpers.BytesToStringLong(Result.EwToEomDistance)
- : "—";
-
- public string CurvePointCountDisplay =>
- Result is not null ? Result.CurvePointCount.ToString("N0") : "0";
-
- public bool IsSaved
- {
- get => _isSaved;
- private set
- {
- if (SetProperty(ref _isSaved, value))
- CommandManager.InvalidateRequerySuggested();
- }
- }
-
- public bool IsApplied
- {
- get => _isApplied;
- private set
- {
- if (SetProperty(ref _isApplied, value))
- CommandManager.InvalidateRequerySuggested();
- }
- }
-
- public string StatusMessage
- {
- get => _statusMessage;
- private set => SetProperty(ref _statusMessage, value);
- }
-
- #endregion
-
- #region Commands
-
- public ICommand StartCommand { get; }
- public ICommand CancelCommand { get; }
- public ICommand SaveProfileCommand { get; }
- public ICommand ApplyProfileCommand { get; }
-
- #endregion
-
- #region Operations
-
- public async Task RunAsync()
- {
- Result = await _tapeService.ExecuteCalibrateAsync(
- new CalibrateRequest(
- EjectWhenDone: EjectWhenDone,
- Options: new TapeCalibrationOptions())
- {
- Cancellation = _abortCts.Token,
- OperationLabel = "Calibration",
- });
-
- return Result;
- }
-
- public void RequestAbort() => _abortCts.Cancel();
-
- private void SaveProfile()
- {
- if (Calibration is null)
- return;
-
- if (!App.Settings.Calibrations.Save(Calibration))
- {
- SimpleBox.Show(
- $"Failed to save the calibration profile.\n\n{App.Settings.Calibrations.LastErrorMessage}",
- "Save Calibration",
- MessageBoxButton.OK,
- SimpleBox.ImageFailed);
- return;
- }
-
- IsSaved = true;
- StatusMessage = "Calibration profile saved.";
- }
-
- private void ApplyProfile()
- {
- if (Calibration is null)
- return;
-
- bool matched = _tapeService.AddCalibration(Calibration);
- IsApplied = true;
- StatusMessage = matched
- ? "Calibration profile applied to the current media."
- : "Calibration profile loaded, but it does not match the current media.";
- _onApplied?.Invoke();
- }
-
- #endregion
-}
diff --git a/TapeWinNET/ViewModels/MainViewModel.Calibration.cs b/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
index d6ea81c..1ae357e 100644
--- a/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
@@ -18,7 +18,7 @@ public partial class MainViewModel
private string _currentCalibrationPhase = string.Empty;
private bool _isCalibrateInProgress;
private bool _isAbortCalibrationEnabled = true;
- private CalibrationViewModel? _activeCalibrationViewModel;
+ private CalibrationRunViewModel? _activeCalibrationViewModel;
#endregion
@@ -101,11 +101,10 @@ private void InitializeCalibrationCommands()
private void ShowCalibrationWindow(object? parameter)
{
- var viewModel = new CalibrationViewModel(
+ var viewModel = new CalibrationRunViewModel(
_tapeService,
OnStartCalibration,
- () => Application.Current.Windows.OfType().FirstOrDefault()?.Close(),
- onApplied: RefreshCurrentView);
+ () => Application.Current.Windows.OfType().FirstOrDefault()?.Close());
var window = new CalibrateWindow(viewModel)
{
@@ -125,13 +124,13 @@ private void ShowCalibrationProfilesWindow(object? parameter)
window.ShowDialog();
}
- private void OnStartCalibration(CalibrationViewModel viewModel)
+ private void OnStartCalibration(CalibrationRunViewModel viewModel)
{
Application.Current.Windows.OfType().FirstOrDefault()?.Close();
_ = ExecuteCalibrationAsync(viewModel);
}
- private async Task ExecuteCalibrationAsync(CalibrationViewModel viewModel)
+ private async Task ExecuteCalibrationAsync(CalibrationRunViewModel viewModel)
{
IsBusy = true;
IsCalibrateInProgress = true;
@@ -175,11 +174,20 @@ private async Task ExecuteCalibrationAsync(CalibrationViewModel viewModel)
CalibrationProgressText = string.Empty;
CurrentCalibrationPhase = string.Empty;
- var resultWindow = new CalibrationWindow(viewModel)
+ var resultViewModel = new CalibrationResultViewModel(_tapeService, operationResult, onApplied: RefreshCurrentView);
+ var resultWindow = new CalibrationWindow(resultViewModel)
{
Owner = Application.Current.MainWindow
};
resultWindow.ShowDialog();
+
+ // The "Run Full Calibration..." follow-up (offered only when a recalibration was found
+ // unreliable) re-opens CalibrateWindow with New preselected, rather than launching a run
+ // directly, so run orchestration stays in one place.
+ if (resultWindow.FullCalibrationRequested)
+ {
+ ShowCalibrationWindow(parameter: null);
+ }
}
catch (Exception ex)
{
diff --git a/docs/Design-RemainingAndEw.md b/docs/Design-RemainingAndEw.md
index d16ed93..4c0a437 100644
--- a/docs/Design-RemainingAndEw.md
+++ b/docs/Design-RemainingAndEw.md
@@ -1284,9 +1284,37 @@ Surface the new `CalibrationMode` in both apps, matching the service extension.
written, HP Ultrium 6, firmware 35GD→35GE"). Wire the selection to `CalibrateRequest.Mode`; on a
`FullRecalibrationAdvised` verdict, route the service's `Confirm` to a WPF dialog; render
`RecalibrationDelta`/`RecalibrationVerdict` in `CalibrationWindow` (before/after rows + verdict banner).
+
+ **[DONE — WPF half]** As-built, this landed as follows:
+ - **`TapeCalibrator.InspectMedia()`** (read-only, `TapeCalibrator.cs`) and the service-level
+ **`TapeServiceBase.ExecuteInspectCalibrationMediaAsync()`** (`TapeServiceBase.Calibrate.cs`) pair
+ back the "Inspect media" button. The service method combines the on-tape header/checkpoint
+ (calibrator) with a `CalibrationStore.Exists(ProfileKey)` lookup to recommend New/Resume/Recalibrate,
+ returned as `InspectCalibrationMediaResult` (`ServiceOperationResult.cs`).
+ - **Inspection is an optional convenience, never a gate** — all three modes stay enabled at all times;
+ a wrong cartridge is already reported by the service with mode-appropriate text. The Inspect Media
+ area is collapsed while "New run" is selected (`CalibrationRunViewModel.IsInspectAvailable`).
+ - **`Confirm` was already WPF-routed** before this work — `WpfServiceHost.Confirm`
+ (`TapeWinNET/Services/WpfServiceHost.cs`) marshals to a `SimpleBox` YesNo on the dispatcher, so no
+ additional wiring was needed for the breach-confirm chain.
+ - **VM split three ways:** `CalibrationRunViewModel` (mode radios, Inspect Media, the destructive run
+ itself — renamed from the old overloaded `CalibrationViewModel`), `CalibrationResultViewModel`
+ (Save/Apply, verdict banner, recalibration delta, the user-driven "Run Full Calibration..." follow-up),
+ and `CalibrationResultViewModelBase` (shared display surface, also the base of
+ `CalibrationProfilesViewModel` so the profiles browser reuses the same figures/verdict members
+ without duplicating them).
+ - **`CalibrationResultView`** (`TapeWinNET/Controls/`) is the extracted shared result `UserControl` —
+ verdict banner, measured-result figures, before/after recalibration delta, and the reported→actual
+ curve — dropped into both `CalibrationWindow.xaml` and `CalibrationProfilesWindow.xaml`, which just
+ inherit its DataContext.
+ - **"Run Full Calibration..."** does not launch a run itself; it closes the result window with
+ `CalibrationResultViewModel.FullCalibrationRequested`, and `MainViewModel.Calibration.cs` re-opens
+ `CalibrateWindow` (New preselected) — keeping run orchestration in one place, in addition to (not
+ instead of) the service's own mid-operation `Confirm`-chain.
- **CLI (`TapeConNET`):** add `--calibrate-resume` and `--calibrate-recheck` (or `--calibrate
--mode=resume|recalibrate`); map `ITapeServiceHost.Confirm` to a Y/N prompt (or `--yes` for
- non-interactive); print the recalibration assessment table and verdict.
+ non-interactive); print the recalibration assessment table and verdict. **[Not yet done — CLI half remains.]**
+
### 7.2 Update a-priori and "LTO-4-like" profiles from the real-hardware data
From eead22670b40267f3289dd2f999ef406452c1612 Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Thu, 20 Aug 2026 18:17:03 +0200
Subject: [PATCH 26/37] Update calibration curve visualization control UI.
---
.../Controls/CalibrationCurveControl.xaml | 26 +-
.../Controls/CalibrationCurveControl.xaml.cs | 510 +++++++++++++-----
.../Controls/CalibrationResultView.xaml | 2 +-
3 files changed, 387 insertions(+), 151 deletions(-)
diff --git a/TapeWinNET/Controls/CalibrationCurveControl.xaml b/TapeWinNET/Controls/CalibrationCurveControl.xaml
index 53e11c8..ca57a68 100644
--- a/TapeWinNET/Controls/CalibrationCurveControl.xaml
+++ b/TapeWinNET/Controls/CalibrationCurveControl.xaml
@@ -16,8 +16,8 @@
-
-
+
-
-
+
-
-
+
+ Margin="8,6,8,0">
+
diff --git a/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs b/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
index b3b7a7e..6bd93fc 100644
--- a/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
+++ b/TapeWinNET/Controls/CalibrationCurveControl.xaml.cs
@@ -17,42 +17,89 @@ namespace TapeWinNET.Controls;
/// function of how much tape is truly left":
/// X = ActualRemaining (ground truth): full capacity on the LEFT, hard EOM (0) on the RIGHT.
/// Y = ReportedRemaining (the driver's figure): full at the TOP, 0 at the bottom.
-/// A faint identity line (Reported == Actual) makes the over/under-report gap obvious at a glance: the
-/// sudden plunge of Reported to 0 at EW (the LTO-3 "collapse") and the phantom free space still claimed
-/// at EOM (LTO-4) both show up directly against it.
+/// A faint identity line (Reported == Actual) makes the over/under-report gap obvious at a glance.
///
///
-/// To magnify the small-but-critical EW→EOM tail, the X axis is split: the body span (capacity → EW)
-/// occupies 80% of the width on the left, and the EW→EOM tail occupies the remaining 20% on the right.
-/// This preserves the overall shape while making the tail readable even when EW sits only a few percent
-/// from EOM.
+/// To magnify the small-but-critical tail, the plot is SPLIT at a single Actual value (the "split point",
+/// default = EW) into a dual-scale plot:
+/// • X: the body span (capacity → split) fills the left part; the split → EOM tail fills the right part.
+/// • Y: the LEFT (body) region keeps the full scale 0 → ReportedCapacityTotal over the full height; the
+/// RIGHT region is INDEPENDENTLY RESCALED to 0 → tailMax over the same full height, where
+/// tailMax sits slightly ABOVE the curve's Reported value at the split, so the magnified
+/// curve starts just below the ceiling rather than glued to it.
+/// Because the two sides carry different Y scales, the blue curve AND the grey identity line each BREAK at
+/// the split line and continue, rescaled, inside the magnified region.
///
///
-/// Hovering marks the "current point" (blue): the Actual-Remaining value under the cursor is snapped
-/// onto the curve and its Actual / Reported readings appear in the top-right corner (free space, since
-/// the curve descends left-to-right). EW is marked in warning-orange, EOM in error-red.
+/// COLOUR SEMANTICS (matched to the app palette):
+/// • orange = "warning" → EW: its dot(s), its vertical guide, and its "EW <value>" axis marker.
+/// When the split sits on EW, the split's Reported label also turns orange.
+/// • red = "error" → EOM: its dot and its "EOM" axis marker.
+/// • curve-blue → the split machinery: the split guide line, the split's X (Actual) and top
+/// (Reported) value labels, plus the hover readout/dot.
+/// • info-blue (faint) → the magnified region's background fill.
+/// When the split coincides with EW, the split line stays blue (it is the axis) and the EW landmark is
+/// shown by ORANGE dots duplicated on BOTH scales; no separate orange guide is drawn to avoid doubling.
+///
+///
+/// The split point is SELECTABLE: left-click sets it to the Actual value under the cursor; clicking within
+/// a small screen-space band of the EW line snaps it onto EW; right-click also snaps it back to EW.
///
///
public partial class CalibrationCurveControl : UserControl
{
- private readonly Polyline _curveLine;
- private readonly Polyline _identityLine;
- private readonly Rectangle _tailShade;
- private readonly Ellipse _ewMarker;
- private readonly Ellipse _eomMarker;
+ // Curve + identity are drawn as TWO polylines each: a body (left, full Y scale) and a tail (right,
+ // rescaled Y scale). They deliberately break at the split line.
+ private readonly Polyline _curveBody;
+ private readonly Polyline _curveTail;
+ private readonly Polyline _identityBody;
+ private readonly Polyline _identityTail;
+
+ private readonly Rectangle _tailShade; // magnified-region fill (info-blue)
+ private readonly Ellipse _eomMarker; // EOM dot (error-red)
+
+ // Split point (selectable): both axes break here. Curve-blue throughout.
+ private readonly Line _splitGuide; // the vertical break line (Actual == split)
+ private readonly TextBlock _splitTopLabel; // the Reported value AT the split, at the top of the line
+ private readonly TextBlock _splitXLabel; // the split's Actual value, under the axis
+
+ // EW landmark (warning-orange).
private readonly Line _ewGuide;
+ private readonly Ellipse _ewDotBody; // EW on the body (full) scale
+ private readonly Ellipse _ewDotTail; // EW duplicated on the rescaled scale (when EW == split)
+ private readonly TextBlock _ewAxisLabel; // "EW " under the X axis, same row as EOM
- // Hover ("current point") visuals.
+ // Hover ("current point") visuals (curve-blue).
private readonly Line _hoverGuide;
private readonly Ellipse _hoverDot;
- // Geometry cached from the last Redraw, so the mouse handler can invert X → ActualRemaining
- // (and place the hover dot) without recomputing the whole plot.
+ // Palette (resolved from App.xaml with fallbacks — see ResolveBrush/ResolveColor).
+ private readonly Brush _curveBrush; // main curve + all split machinery + hover
+ private readonly Brush _warnBrush; // EW (orange)
+ private readonly Brush _errBrush; // EOM (red)
+
+ // Fraction of the plot width given to the magnified tail. A third reads well while still leaving the
+ // body recognisable. Tune freely (0.30–0.40 all work).
+ private const double TailFraction = 0.33;
+
+ // Headroom above the split's Reported value for the rescaled ceiling, so the tail curve starts a touch
+ // below the top instead of hugging it. No "nice" rounding — the top label prints the real split value.
+ private const double TailHeadroom = 1.12;
+
+ // Click within this many pixels of the EW line snaps the split back onto EW — otherwise the split
+ // markers/line overlap the EW markers/line into an unreadable tangle.
+ private const double SnapToEwPx = 12.0;
+
+ // Geometry cached from the last Redraw, so the mouse handlers can invert X → ActualRemaining etc.
private double _bodyWidth;
private double _tailWidth;
- private long _ewActual; // ActualRemaining at the EW landmark (0 when no EW); the body/tail split
- private long _actualMax; // CapacityActual — left edge of the X axis
- private long _reportedMax; // ReportedCapacityTotal — top of the Y axis
+ private long _splitActual; // ActualRemaining at the split (body/tail boundary on X); 0 → no split
+ private long _splitReported; // ReportedRemaining at the split point (the rescale reference)
+ private long _tailReportedMax; // rescaled Y ceiling: slightly above the split's Reported value
+ private long? _userSplitActual; // user-chosen split; null → follow the EW landmark
+ private long _ewActual; // ActualRemaining at the EW landmark
+ private long _actualMax; // CapacityActual — left edge of the X axis
+ private long _reportedMax; // ReportedCapacityTotal — top of the body (left) Y scale
public static readonly DependencyProperty CalibrationProperty =
DependencyProperty.Register(
@@ -71,58 +118,46 @@ public CalibrationCurveControl()
{
InitializeComponent();
- _tailShade = new Rectangle
- {
- Fill = new SolidColorBrush(Color.FromArgb(32, 255, 165, 0)),
- IsHitTestVisible = false,
- };
+ // --- Palette. Point these keys at your real App.xaml brushes; the fallbacks keep it compiling. ---
+ _curveBrush = WpfTheme.AccentBlueDarkBrush; // main curve blue
+ _warnBrush = ResolveBrush("WarningBrush", Color.FromRgb(0xE8, 0x8A, 0x00)); // orange
+ _errBrush = ResolveBrush("ErrorBrush", Color.FromRgb(0xC5, 0x28, 0x2B)); // red
+ Color infoColor = ResolveColor("InfoBrush", Color.FromRgb(0x2B, 0x88, 0xD8)); // info-blue
+ var infoFill = new SolidColorBrush(Color.FromArgb(30, infoColor.R, infoColor.G, infoColor.B));
- // Faint reference line: where the driver would sit if it reported the truth (Reported == Actual).
- _identityLine = new Polyline
- {
- Stroke = new SolidColorBrush(Color.FromArgb(96, 128, 128, 128)),
- StrokeThickness = 1,
- StrokeDashArray = [3, 3],
- IsHitTestVisible = false,
- };
+ _tailShade = new Rectangle { Fill = infoFill, IsHitTestVisible = false };
- _curveLine = new Polyline
+ _identityBody = MakeIdentityLine();
+ _identityTail = MakeIdentityLine();
+ _curveBody = MakeCurveLine(_curveBrush);
+ _curveTail = MakeCurveLine(_curveBrush);
+
+ _splitGuide = new Line
{
- Stroke = WpfTheme.AccentBlueDarkBrush,
- StrokeThickness = 2,
- StrokeLineJoin = PenLineJoin.Round,
+ Stroke = _curveBrush,
+ StrokeThickness = 1.5,
+ StrokeDashArray = [4, 2],
IsHitTestVisible = false,
+ Visibility = Visibility.Collapsed,
};
+ _splitTopLabel = MakeAxisLabel(_curveBrush);
+ _splitXLabel = MakeAxisLabel(_curveBrush);
+
_ewGuide = new Line
{
- Stroke = Brushes.DarkOrange,
+ Stroke = _warnBrush,
StrokeThickness = 1.5,
StrokeDashArray = [4, 2],
IsHitTestVisible = false,
Visibility = Visibility.Collapsed,
};
- _ewMarker = new Ellipse
- {
- Width = 8,
- Height = 8,
- Fill = Brushes.DarkOrange, // warning-orange: early warning
- Stroke = Brushes.White,
- StrokeThickness = 1,
- Visibility = Visibility.Collapsed,
- IsHitTestVisible = false,
- };
+ _ewDotBody = MakeDot(8, _warnBrush);
+ _ewDotTail = MakeDot(8, _warnBrush);
+ _ewAxisLabel = MakeAxisLabel(_warnBrush);
- _eomMarker = new Ellipse
- {
- Width = 8,
- Height = 8,
- Fill = Brushes.Firebrick, // error-red: end of medium
- Stroke = Brushes.White,
- StrokeThickness = 1,
- IsHitTestVisible = false,
- };
+ _eomMarker = MakeDot(8, _errBrush);
_hoverGuide = new Line
{
@@ -132,70 +167,136 @@ public CalibrationCurveControl()
Visibility = Visibility.Collapsed,
};
- _hoverDot = new Ellipse
- {
- Width = 9,
- Height = 9,
- Fill = WpfTheme.AccentBlueDarkBrush, // current point: blue
- Stroke = Brushes.White,
- StrokeThickness = 1,
- IsHitTestVisible = false,
- Visibility = Visibility.Collapsed,
- };
+ _hoverDot = MakeDot(9, _curveBrush);
+ _hoverDot.Visibility = Visibility.Collapsed;
+
+ HoverReadout.Foreground = _curveBrush;
- // Z-order: shade, identity, curve, guides, then markers and the hover dot on top.
+ // Z-order: fill, identity, curve, guides, EW/EOM dots, hover, labels.
PlotCanvas.Children.Add(_tailShade);
- PlotCanvas.Children.Add(_identityLine);
- PlotCanvas.Children.Add(_curveLine);
+ PlotCanvas.Children.Add(_identityBody);
+ PlotCanvas.Children.Add(_identityTail);
+ PlotCanvas.Children.Add(_curveBody);
+ PlotCanvas.Children.Add(_curveTail);
+ PlotCanvas.Children.Add(_splitGuide);
PlotCanvas.Children.Add(_ewGuide);
PlotCanvas.Children.Add(_hoverGuide);
- PlotCanvas.Children.Add(_ewMarker);
+ PlotCanvas.Children.Add(_ewDotBody);
+ PlotCanvas.Children.Add(_ewDotTail);
PlotCanvas.Children.Add(_eomMarker);
PlotCanvas.Children.Add(_hoverDot);
+ PlotCanvas.Children.Add(_splitTopLabel);
+
+ ActualAxisCanvas.Children.Add(_ewAxisLabel);
+ ActualAxisCanvas.Children.Add(_splitXLabel);
PlotCanvas.SizeChanged += (_, _) => Redraw();
PlotCanvas.MouseMove += OnPlotMouseMove;
PlotCanvas.MouseLeave += OnPlotMouseLeave;
+ PlotCanvas.MouseLeftButtonDown += OnPlotMouseLeftDown; // pick the split point
+ PlotCanvas.MouseRightButtonDown += OnPlotMouseRightDown; // snap the split back to EW
}
+ #region *** Factory helpers ***
+
+ private static Polyline MakeCurveLine(Brush stroke) => new()
+ {
+ Stroke = stroke,
+ StrokeThickness = 2,
+ StrokeLineJoin = PenLineJoin.Round,
+ IsHitTestVisible = false,
+ };
+
+ private static Polyline MakeIdentityLine() => new()
+ {
+ Stroke = new SolidColorBrush(Color.FromArgb(96, 128, 128, 128)),
+ StrokeThickness = 1,
+ StrokeDashArray = [3, 3],
+ IsHitTestVisible = false,
+ };
+
+ private static Ellipse MakeDot(double size, Brush fill) => new()
+ {
+ Width = size,
+ Height = size,
+ Fill = fill,
+ Stroke = Brushes.White,
+ StrokeThickness = 1,
+ IsHitTestVisible = false,
+ Visibility = Visibility.Collapsed,
+ };
+
+ private static TextBlock MakeAxisLabel(Brush fg) => new()
+ {
+ Foreground = fg,
+ FontSize = 11,
+ FontWeight = FontWeights.SemiBold,
+ IsHitTestVisible = false,
+ Visibility = Visibility.Collapsed,
+ };
+
+ private static void PlaceDot(Ellipse dot, double cx, double cy)
+ {
+ dot.Visibility = Visibility.Visible;
+ Canvas.SetLeft(dot, cx - (dot.Width / 2));
+ Canvas.SetTop(dot, cy - (dot.Height / 2));
+ }
+
+ private Brush ResolveBrush(string key, Color fallback)
+ => TryFindResource(key) as Brush ?? new SolidColorBrush(fallback);
+
+ private Color ResolveColor(string key, Color fallback)
+ => TryFindResource(key) switch
+ {
+ SolidColorBrush b => b.Color,
+ Color c => c,
+ _ => fallback,
+ };
+
+ #endregion
+
private static void OnCalibrationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
=> ((CalibrationCurveControl)d).Redraw();
#region *** Axis mapping (uses cached geometry) ***
- // ActualRemaining → X. Full capacity maps to the LEFT (x = 0), EOM (0) to the RIGHT (x = w). The
- // body span [ewActual, actualMax] occupies the left bodyWidth; the tail [0, ewActual] the right tailWidth.
private double MapX(long actualRemaining)
{
- if (_ewActual > 0 && actualRemaining <= _ewActual)
+ if (_splitActual > 0 && actualRemaining <= _splitActual)
{
- double tTail = (double)actualRemaining / _ewActual; // 1 at EW, 0 at EOM
+ double tTail = (double)actualRemaining / _splitActual; // 1 at split, 0 at EOM
return _bodyWidth + (1.0 - tTail) * _tailWidth;
}
-
- double topSpan = Math.Max(1L, _actualMax - _ewActual);
- double tBody = (double)(actualRemaining - _ewActual) / topSpan; // 0 at EW, 1 at capacity
+ double topSpan = Math.Max(1L, _actualMax - _splitActual);
+ double tBody = (double)(actualRemaining - _splitActual) / topSpan; // 0 at split, 1 at capacity
return (1.0 - tBody) * _bodyWidth;
}
- // ReportedRemaining → Y. Full at the top (y = 0), 0 at the bottom (y = h).
- private double MapY(long reportedRemaining, double h)
+ // Reported → Y on the BODY (left) scale: 0 → reportedMax over the full height.
+ private double MapYBody(long reportedRemaining, double h)
=> h - ((double)reportedRemaining / Math.Max(1L, _reportedMax)) * h;
- // X → ActualRemaining (inverse of MapX), for the hover readout.
+ // Reported → Y on the rescaled TAIL (right) scale: 0 → tailMax over the full height.
+ private double MapYTail(long reportedRemaining, double h)
+ => h - ((double)reportedRemaining / Math.Max(1L, _tailReportedMax)) * h;
+
+ // Region-aware Y: points at/under the split use the rescaled scale, the rest the body scale.
+ private double MapY(long actualRemaining, long reportedRemaining, double h)
+ => (_splitActual > 0 && actualRemaining <= _splitActual)
+ ? MapYTail(reportedRemaining, h)
+ : MapYBody(reportedRemaining, h);
+
private long InvertX(double x)
{
double w = _bodyWidth + _tailWidth;
x = Math.Clamp(x, 0.0, w);
-
- if (_ewActual > 0 && x >= _bodyWidth)
+ if (_splitActual > 0 && x >= _bodyWidth)
{
double tTail = _tailWidth > 0 ? 1.0 - (x - _bodyWidth) / _tailWidth : 1.0;
- return (long)(Math.Clamp(tTail, 0.0, 1.0) * _ewActual);
+ return (long)(Math.Clamp(tTail, 0.0, 1.0) * _splitActual);
}
-
- double tBodyFromLeft = _bodyWidth > 0 ? x / _bodyWidth : 0.0; // 0 at left (capacity), 1 at EW
- return _ewActual + (long)((1.0 - Math.Clamp(tBodyFromLeft, 0.0, 1.0)) * (_actualMax - _ewActual));
+ double tBodyFromLeft = _bodyWidth > 0 ? x / _bodyWidth : 0.0; // 0 at left (capacity), 1 at split
+ return _splitActual + (long)((1.0 - Math.Clamp(tBodyFromLeft, 0.0, 1.0)) * (_actualMax - _splitActual));
}
#endregion
@@ -204,92 +305,229 @@ private void Redraw()
{
double w = PlotCanvas.ActualWidth;
double h = PlotCanvas.ActualHeight;
-
HideHover();
if (w < 2 || h < 2 || Calibration is null || Calibration.Curve.Count == 0)
{
- _curveLine.Points.Clear();
- _identityLine.Points.Clear();
- _ewGuide.Visibility = Visibility.Collapsed;
- _ewMarker.Visibility = Visibility.Collapsed;
- _eomMarker.Visibility = Visibility.Collapsed;
- _tailShade.Visibility = Visibility.Collapsed;
+ ClearAll();
return;
}
ITapeCalibration calibration = Calibration;
-
_reportedMax = Math.Max(1L, calibration.ReportedCapacityTotal);
_actualMax = Math.Max(1L, calibration.CapacityActual);
_ewActual = Math.Max(0L, calibration.EarlyWarning?.ActualRemaining ?? 0L);
- const double tailFraction = 0.20;
- _bodyWidth = _ewActual > 0 ? w * (1.0 - tailFraction) : w;
- _tailWidth = _ewActual > 0 ? w * tailFraction : 0.0;
+ long candidate = _userSplitActual ?? _ewActual;
+ _splitActual = candidate > 0 ? Math.Clamp(candidate, 1L, Math.Max(1L, _actualMax - 1)) : 0L;
+ bool split = _splitActual > 0;
- Point MapPoint(CalibrationPoint point)
- => new(Math.Clamp(MapX(point.ActualRemaining), 0.0, w),
- Math.Clamp(MapY(point.ReportedRemaining, h), 0.0, h));
+ _splitReported = split ? Math.Clamp(calibration.TranslateActualToReported(_splitActual), 0L, _reportedMax) : 0L;
- // The measured curve: driver-reported (Y) against true remaining (X).
- _curveLine.Points = [.. calibration.Curve.Select(MapPoint)];
+ // The rescaled ceiling sits slightly ABOVE the split's Reported value, so the magnified curve starts
+ // a little below the top rather than glued to it. No "nice" rounding — no meaning here.
+ _tailReportedMax = split ? Math.Max(_splitReported + 1, (long)(_splitReported * TailHeadroom)) : 1L;
- // The identity reference at the same X positions: where Reported would equal Actual.
- _identityLine.Points =
- [
- .. calibration.Curve.Select(p =>
- new Point(Math.Clamp(MapX(p.ActualRemaining), 0.0, w),
- Math.Clamp(MapY(p.ActualRemaining, h), 0.0, h)))
- ];
+ _bodyWidth = split ? w * (1.0 - TailFraction) : w;
+ _tailWidth = split ? w * TailFraction : 0.0;
- // Axis labels: Y (left) = Reported; X (bottom) = Actual, full-capacity → EOM.
+ double X(long a) => Math.Clamp(MapX(a), 0.0, w);
+ double Yb(long r) => Math.Clamp(MapYBody(r, h), 0.0, h);
+ double Yt(long r) => Math.Clamp(MapYTail(r, h), 0.0, h);
+
+ // --- Body + tail polylines, breaking at the split line. -----------------------------------------
+ var pts = calibration.Curve.OrderByDescending(p => p.ActualRemaining).ToList();
+ var curveBody = new PointCollection();
+ var curveTail = new PointCollection();
+ var idBody = new PointCollection();
+ var idTail = new PointCollection();
+
+ if (split)
+ {
+ foreach (CalibrationPoint p in pts)
+ {
+ double x = X(p.ActualRemaining);
+ if (p.ActualRemaining > _splitActual)
+ {
+ curveBody.Add(new Point(x, Yb(p.ReportedRemaining)));
+ idBody.Add(new Point(x, Yb(p.ActualRemaining)));
+ }
+ else if (p.ActualRemaining < _splitActual)
+ {
+ curveTail.Add(new Point(x, Yt(p.ReportedRemaining)));
+ idTail.Add(new Point(x, Yt(p.ActualRemaining)));
+ }
+ }
+
+ double xs = X(_splitActual); // == _bodyWidth
+ curveBody.Add(new Point(xs, Yb(_splitReported))); // body half ends low
+ idBody.Add(new Point(xs, Yb(_splitActual)));
+ curveTail.Insert(0, new Point(xs, Yt(_splitReported))); // tail half starts high
+ idTail.Insert(0, new Point(xs, Yt(_splitActual)));
+ }
+ else
+ {
+ foreach (CalibrationPoint p in pts)
+ {
+ double x = X(p.ActualRemaining);
+ curveBody.Add(new Point(x, Yb(p.ReportedRemaining)));
+ idBody.Add(new Point(x, Yb(p.ActualRemaining)));
+ }
+ }
+
+ _curveBody.Points = curveBody;
+ _curveTail.Points = curveTail;
+ _identityBody.Points = idBody;
+ _identityTail.Points = idTail;
+
+ // --- Axis labels --------------------------------------------------------------------------------
ReportedTopLabel.Text = Helpers.BytesToString(_reportedMax);
ReportedBottomLabel.Text = "0";
ActualLeftLabel.Text = Helpers.BytesToString(_actualMax);
ActualRightLabel.Text = "EOM";
+ ActualRightLabel.Foreground = _errBrush; // EOM axis marker → error-red
- if (_ewActual > 0 && calibration.EarlyWarning is { } ew)
+ // --- Split visuals: info-blue fill, blue break line, blue value labels --------------------------
+ bool splitOnEw = split && _splitActual == _ewActual;
+ if (split)
{
- var ewPoint = MapPoint(ew);
-
- _ewGuide.Visibility = Visibility.Visible;
- _ewGuide.X1 = ewPoint.X;
- _ewGuide.X2 = ewPoint.X;
- _ewGuide.Y1 = 0;
- _ewGuide.Y2 = h;
-
- _ewMarker.Visibility = Visibility.Visible;
- Canvas.SetLeft(_ewMarker, ewPoint.X - (_ewMarker.Width / 2));
- Canvas.SetTop(_ewMarker, ewPoint.Y - (_ewMarker.Height / 2));
+ double xs = _bodyWidth;
_tailShade.Visibility = Visibility.Visible;
_tailShade.Width = _tailWidth;
_tailShade.Height = h;
- Canvas.SetLeft(_tailShade, _bodyWidth);
+ Canvas.SetLeft(_tailShade, xs);
Canvas.SetTop(_tailShade, 0);
+
+ _splitGuide.Visibility = Visibility.Visible;
+ _splitGuide.X1 = _splitGuide.X2 = xs;
+ _splitGuide.Y1 = 0;
+ _splitGuide.Y2 = h;
+
+ // The Reported value AT the split, at the top of the split line (mirrors the Actual split value
+ // under the X axis). Orange when the split sits on EW (matches the orange EW Actual label).
+ _splitTopLabel.Visibility = Visibility.Visible;
+ _splitTopLabel.Foreground = splitOnEw ? _warnBrush : _curveBrush;
+ _splitTopLabel.Text = Helpers.BytesToString(_splitReported);
+ _splitTopLabel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+ Canvas.SetLeft(_splitTopLabel, Math.Max(0.0, xs - _splitTopLabel.DesiredSize.Width - 4));
+ Canvas.SetTop(_splitTopLabel, 2);
+
+ // The split's Actual value under the axis — only when the split has moved OFF EW (else the
+ // orange "EW " already carries the number and blue+orange would collide).
+ if (!splitOnEw)
+ {
+ _splitXLabel.Visibility = Visibility.Visible;
+ _splitXLabel.Text = Helpers.BytesToString(_splitActual);
+ _splitXLabel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+ Canvas.SetLeft(_splitXLabel, xs - (_splitXLabel.DesiredSize.Width / 2));
+ Canvas.SetTop(_splitXLabel, 0);
+ }
+ else
+ {
+ _splitXLabel.Visibility = Visibility.Collapsed;
+ }
}
else
{
- _ewGuide.Visibility = Visibility.Collapsed;
- _ewMarker.Visibility = Visibility.Collapsed;
_tailShade.Visibility = Visibility.Collapsed;
+ _splitGuide.Visibility = Visibility.Collapsed;
+ _splitTopLabel.Visibility = Visibility.Collapsed;
+ _splitXLabel.Visibility = Visibility.Collapsed;
+ }
+
+ // --- EW landmark (orange): dot(s), guide, "EW " axis marker. -----------------------------
+ if (_ewActual > 0 && calibration.EarlyWarning is { } ew)
+ {
+ double ewX = X(ew.ActualRemaining);
+
+ _ewAxisLabel.Visibility = Visibility.Visible;
+ _ewAxisLabel.Text = "EW " + Helpers.BytesToString(ew.ActualRemaining);
+ _ewAxisLabel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+ Canvas.SetLeft(_ewAxisLabel, Math.Max(0.0, ewX - (_ewAxisLabel.DesiredSize.Width / 2)));
+ Canvas.SetTop(_ewAxisLabel, 0);
+
+ if (splitOnEw)
+ {
+ // On the split line: duplicate the orange dot on BOTH scales; the blue split line marks
+ // the axis, so no separate orange guide is needed.
+ _ewGuide.Visibility = Visibility.Collapsed;
+ PlaceDot(_ewDotBody, ewX, Yb(_splitReported)); // low
+ PlaceDot(_ewDotTail, ewX, Yt(_splitReported)); // high
+ }
+ else
+ {
+ _ewGuide.Visibility = Visibility.Visible;
+ _ewGuide.X1 = _ewGuide.X2 = ewX;
+ _ewGuide.Y1 = 0;
+ _ewGuide.Y2 = h;
+ double ey = Math.Clamp(MapY(ew.ActualRemaining, ew.ReportedRemaining, h), 0.0, h);
+ PlaceDot(_ewDotBody, ewX, ey);
+ _ewDotTail.Visibility = Visibility.Collapsed;
+ }
+ }
+ else
+ {
+ _ewGuide.Visibility = Visibility.Collapsed;
+ _ewDotBody.Visibility = Visibility.Collapsed;
+ _ewDotTail.Visibility = Visibility.Collapsed;
+ _ewAxisLabel.Visibility = Visibility.Collapsed;
}
- // EOM sits at ActualRemaining == 0; its Y encodes the phantom free space still claimed there.
- var eomPoint = MapPoint(new CalibrationPoint(calibration.PhantomFreeAtEom, 0));
- _eomMarker.Visibility = Visibility.Visible;
- Canvas.SetLeft(_eomMarker, eomPoint.X - (_eomMarker.Width / 2));
- Canvas.SetTop(_eomMarker, eomPoint.Y - (_eomMarker.Height / 2));
+ // --- EOM (red): ActualRemaining == 0, rescaled Y when split is on; Y = phantom free claimed. -----
+ long eomReported = calibration.PhantomFreeAtEom;
+ PlaceDot(_eomMarker, X(0), Math.Clamp(MapY(0, eomReported, h), 0.0, h));
}
+ private void ClearAll()
+ {
+ _curveBody.Points.Clear();
+ _curveTail.Points.Clear();
+ _identityBody.Points.Clear();
+ _identityTail.Points.Clear();
+ _tailShade.Visibility = Visibility.Collapsed;
+ _splitGuide.Visibility = Visibility.Collapsed;
+ _splitTopLabel.Visibility = Visibility.Collapsed;
+ _splitXLabel.Visibility = Visibility.Collapsed;
+ _ewGuide.Visibility = Visibility.Collapsed;
+ _ewDotBody.Visibility = Visibility.Collapsed;
+ _ewDotTail.Visibility = Visibility.Collapsed;
+ _ewAxisLabel.Visibility = Visibility.Collapsed;
+ _eomMarker.Visibility = Visibility.Collapsed;
+ }
+
+ #region *** Split selection ***
+
+ private void OnPlotMouseLeftDown(object sender, MouseButtonEventArgs e)
+ {
+ if (Calibration is null || _actualMax <= 0)
+ return;
+
+ // Snap to EW when the click lands within a small screen-space band of the EW line — otherwise the
+ // split markers/line would overlap the EW markers/line into an unreadable tangle.
+ double clickX = e.GetPosition(PlotCanvas).X;
+ if (_ewActual > 0 && Math.Abs(clickX - MapX(_ewActual)) <= SnapToEwPx)
+ _userSplitActual = null; // follow the EW landmark
+ else
+ _userSplitActual = InvertX(clickX);
+
+ Redraw();
+ }
+
+ private void OnPlotMouseRightDown(object sender, MouseButtonEventArgs e)
+ {
+ _userSplitActual = null; // snap back to the EW landmark
+ Redraw();
+ }
+
+ #endregion
+
#region *** Hover ("current point") ***
private void OnPlotMouseMove(object sender, MouseEventArgs e)
{
double w = PlotCanvas.ActualWidth;
double h = PlotCanvas.ActualHeight;
-
if (w < 2 || h < 2 || Calibration is null || Calibration.Curve.Count == 0)
{
HideHover();
@@ -297,16 +535,14 @@ private void OnPlotMouseMove(object sender, MouseEventArgs e)
}
double x = e.GetPosition(PlotCanvas).X;
-
long actual = InvertX(x);
long reported = Calibration.TranslateActualToReported(actual); // snap onto the curve
double px = Math.Clamp(MapX(actual), 0.0, w);
- double py = Math.Clamp(MapY(reported, h), 0.0, h);
+ double py = Math.Clamp(MapY(actual, reported, h), 0.0, h); // region-aware: rescaled near EOM
_hoverGuide.Visibility = Visibility.Visible;
- _hoverGuide.X1 = px;
- _hoverGuide.X2 = px;
+ _hoverGuide.X1 = _hoverGuide.X2 = px;
_hoverGuide.Y1 = 0;
_hoverGuide.Y2 = h;
diff --git a/TapeWinNET/Controls/CalibrationResultView.xaml b/TapeWinNET/Controls/CalibrationResultView.xaml
index 5e80f2d..3ef10ba 100644
--- a/TapeWinNET/Controls/CalibrationResultView.xaml
+++ b/TapeWinNET/Controls/CalibrationResultView.xaml
@@ -134,7 +134,7 @@
Calibration="{Binding Calibration}"/>
Date: Sat, 22 Aug 2026 14:40:42 +0200
Subject: [PATCH 27/37] Improve recalibration TapeLibNET service and TapeWinNET
UI.
---
.../Services/TapeServiceBase.Calibrate.cs | 81 ++++++++++++++-----
TapeLibNET/TapeCalibrator.cs | 7 +-
TapeWinNET/CalibrateWindow.xaml | 8 +-
.../ViewModels/CalibrationRunViewModel.cs | 22 ++++-
4 files changed, 90 insertions(+), 28 deletions(-)
diff --git a/TapeLibNET/Services/TapeServiceBase.Calibrate.cs b/TapeLibNET/Services/TapeServiceBase.Calibrate.cs
index f7a1cc1..eddf851 100644
--- a/TapeLibNET/Services/TapeServiceBase.Calibrate.cs
+++ b/TapeLibNET/Services/TapeServiceBase.Calibrate.cs
@@ -358,9 +358,24 @@ public bool AddCalibration(ITapeCalibration calibration)
///
/// Non-destructively probes the loaded cartridge for an existing calibration trail, combining the
/// on-tape header/checkpoint () with a
- /// lookup by profile key to recommend a .
- /// This is a pure convenience for the UI — it never gates New/Resume/Recalibrate, which all remain
- /// available regardless of the result.
+ /// lookup to recommend a .
+ /// This is a pure convenience for the UI — it doesn't gate New/Resume/Recalibrate, which all remain
+ /// available regardless of the result, since Resume/Recalibrate will fail gracefully if the cartridge
+ /// is unsuitable.
+ ///
+ /// Recommendation logic. Resume AND Recalibrate both require a valid ON-TAPE checkpoint — no stored
+ /// profile can substitute for a checkpoint that is not physically on the cartridge. Recalibrate
+ /// additionally needs a COMPLETE run plus a baseline to compare against.
+ ///
+ ///
+ /// Cartridge state | IsResumable | AppearsComplete | hasBaseline | Recommend
+ /// ---------------------------+-------------+-----------------+-------------+-----------
+ /// No header (blank/foreign) | — | — | — | New
+ /// Header only, no checkpoint | false | — | — | New
+ /// Interrupted run | true | false | — | Resume
+ /// Complete run, no baseline | true | true | false | Resume
+ /// Complete run + baseline | true | true | true | Recalibrate
+ ///
///
public Task ExecuteInspectCalibrationMediaAsync()
{
@@ -394,31 +409,61 @@ public Task ExecuteInspectCalibrationMediaAsync()
Success = true,
Outcome = ServiceReportLevel.Info,
HasRunHeader = false,
+ RecommendedMode = CalibrationMode.New,
Summary = "No calibration trail found on this cartridge — a New run is required.",
};
}
bool matchesDrive = string.Equals(info.ProfileKey, _drive.DriveProfileKey, StringComparison.Ordinal);
- bool hasStored = CalibrationStore.Exists(info.ProfileKey);
- CalibrationMode recommended = hasStored
- ? CalibrationMode.Recalibrate
- : info.IsResumable
- ? CalibrationMode.Resume
- : CalibrationMode.New;
+ // The baseline that makes Recalibrate MEANINGFUL is resolved exactly as ExecuteCalibrateCore
+ // does — the drive's active calibration, else the store keyed by the CURRENT drive
+ // (NOT the trail's recorded key, which may differ if the cartridge came from another drive).
+ bool hasBaseline = _drive.Calibration is not null
+ || CalibrationStore.Exists(_drive.DriveProfileKey);
+
+ // Gate on the actual ON-TAPE state first (IsResumable), then completeness + baseline.
+ // See the table in the method summary.
+ CalibrationMode recommended;
+ string summary;
- string summary = hasStored
- ? $"A complete, stored calibration exists for this cartridge (started {info.StartedUtc:u}) — Recalibrate recommended."
- : info.IsResumable
- ? $"An interrupted run was found ({info.ProgressFraction:P0} written, started {info.StartedUtc:u}) — Resume recommended."
- : "A calibration header was found, but no valid checkpoint — the run cannot be resumed.";
+ if (!info.IsResumable)
+ {
+ // Header present but no valid checkpoint (run died before the first checkpoint, or all
+ // checkpoints are torn) — nothing to resume from and nothing to recalibrate.
+ recommended = CalibrationMode.New;
+ summary = "A calibration header is present, but no valid checkpoint could be read from "
+ + "this cartridge — it cannot be resumed or recalibrated. Run a New calibration.";
+ }
+ else if (info.AppearsComplete && hasBaseline)
+ {
+ recommended = CalibrationMode.Recalibrate;
+ summary = $"A completed calibration is on this cartridge (started {info.StartedUtc:u}). "
+ + "Recalibrate quickly re-measures the tail and compares it against the existing profile.";
+ }
+ else if (info.AppearsComplete)
+ {
+ // Complete trail, but no stored profile to compare — resuming re-measures the tail into
+ // a fresh profile (equivalent work; there is simply nothing to diff against).
+ recommended = CalibrationMode.Resume;
+ summary = $"A completed calibration run is on this cartridge (started {info.StartedUtc:u}), "
+ + "but no stored profile to compare against. Resume re-measures the tail into a fresh profile.";
+ }
+ else
+ {
+ recommended = CalibrationMode.Resume;
+ summary = $"An interrupted run was found ({info.ProgressFraction:P0} written, started "
+ + $"{info.StartedUtc:u}). Resume continues it to completion.";
+ }
if (!matchesDrive)
- summary += " Note: this trail belongs to a different drive/media profile.";
+ summary += " Note: this trail was recorded on a different drive/media profile.";
LogInfo("Media inspection:");
- LogInfoSub($"Profile key: >{info.ProfileKey}<");
- LogInfoSub($"Started: {info.StartedUtc:u}, resumable: {info.IsResumable}, stored: {hasStored}");
+ LogInfoSub($">{info.ProfileKey}<");
+ LogInfoSub($"Started: {info.StartedUtc:u}, resumable: {info.IsResumable}, " +
+ $"complete: {info.AppearsComplete}, baseline: {hasBaseline}");
+ LogInfoSub($"Recommended mode: {recommended}");
return new InspectCalibrationMediaResult
{
@@ -432,7 +477,7 @@ public Task ExecuteInspectCalibrationMediaAsync()
BytesWritten = info.CheckpointedBytes,
ProgressFraction = info.ProgressFraction,
MatchesCurrentDrive = matchesDrive,
- HasStoredCalibration = hasStored,
+ HasStoredCalibration = hasBaseline,
RecommendedMode = recommended,
Summary = summary,
};
diff --git a/TapeLibNET/TapeCalibrator.cs b/TapeLibNET/TapeCalibrator.cs
index 726a551..d02617f 100644
--- a/TapeLibNET/TapeCalibrator.cs
+++ b/TapeLibNET/TapeCalibrator.cs
@@ -744,8 +744,11 @@ private bool EstablishBomCapacity(out long capacityReportedAtBom)
// wrong cartridge (ordinary backup sets, no calibration trail) is rejected in one traversal
if (!Drive.FastforwardToEnd(MediaPartition.Content))
{
- SyncErrorFrom(Drive);
- return null;
+ m_logger.LogInformation(
+ "{Prefix}: Resume — seek-to-EOD failed ({Err}); cartridge is likely full to EOM w/o EOD mark, " +
+ "proceeding from the current (end-of-data) position",
+ LogPrefix, Drive.LastErrorMessage);
+ ResetError();
}
// Back up before the last filemark; none present ⇒ no resumable run (header-only / blank).
diff --git a/TapeWinNET/CalibrateWindow.xaml b/TapeWinNET/CalibrateWindow.xaml
index 4050854..2c86a4d 100644
--- a/TapeWinNET/CalibrateWindow.xaml
+++ b/TapeWinNET/CalibrateWindow.xaml
@@ -93,18 +93,18 @@
help:HelpTopicIdAttachedProperty.TopicId="dialog.calibrate"
help:HelpControlNameAttachedProperty.ControlName="Operation">
-
-
-
-
diff --git a/TapeWinNET/ViewModels/CalibrationRunViewModel.cs b/TapeWinNET/ViewModels/CalibrationRunViewModel.cs
index a953e1a..22e6a9d 100644
--- a/TapeWinNET/ViewModels/CalibrationRunViewModel.cs
+++ b/TapeWinNET/ViewModels/CalibrationRunViewModel.cs
@@ -156,12 +156,26 @@ private async Task InspectMediaAsync()
InspectionSummary = result.Success
? result.Summary
: $"Inspection failed: {result.Message}";
- InspectionLevel = result.Success
- ? (result.HasRunHeader ? WarningLevel.Info : WarningLevel.Warning)
- : WarningLevel.Failed;
+
+ // Severity mirrors the cartridge state the service resolved, not merely "has a header":
+ // - failure → Failed
+ // - no trail (New required) → Info (normal — a scratch cartridge)
+ // - header present but no checkpoint→ Warning (a trail exists but is unusable)
+ // - trail from a different drive → Warning (usable, but worth flagging)
+ // - resumable/complete on this drive→ Info
+ InspectionLevel = !result.Success
+ ? WarningLevel.Failed
+ : !result.HasRunHeader
+ ? WarningLevel.Info
+ : (!result.HasCheckpoint || !result.MatchesCurrentDrive)
+ ? WarningLevel.Warning
+ : WarningLevel.Info;
+
HasInspectionResult = true;
- if (result.RecommendedMode is { } recommended)
+ // Default the mode selector to the recommendation — but only on a successful read, so a failed
+ // inspection never silently flips the user's chosen mode (e.g. back to New).
+ if (result.Success && result.RecommendedMode is { } recommended)
SelectedMode = recommended;
}
finally
From 7d23dbe1934a542e901c392563bd7d65b1cfb5f2 Mon Sep 17 00:00:00 2001
From: Alex K
Date: Sat, 22 Aug 2026 19:08:32 +0200
Subject: [PATCH 28/37] Update TapeLibNET services to warn on attempting to
calibrate a multi-partition media; allow upon confirmation. Update TapeWinNET
UI to disable calibration of multi-partition media and restrict applying
calibration profiles to single-partition media only.
---
.../Services/TapeServiceBase.Calibrate.cs | 33 ++++++++++++++++++-
TapeLibNET/TapeDriveWin32Backend.cs | 2 +-
TapeWinNET/CalibrationProfilesWindow.xaml | 2 +-
TapeWinNET/CalibrationWindow.xaml | 2 +-
.../CalibrationProfilesViewModel.cs | 2 +-
.../ViewModels/CalibrationResultViewModel.cs | 2 +-
.../ViewModels/MainViewModel.Calibration.cs | 2 +-
7 files changed, 38 insertions(+), 7 deletions(-)
diff --git a/TapeLibNET/Services/TapeServiceBase.Calibrate.cs b/TapeLibNET/Services/TapeServiceBase.Calibrate.cs
index eddf851..9745eea 100644
--- a/TapeLibNET/Services/TapeServiceBase.Calibrate.cs
+++ b/TapeLibNET/Services/TapeServiceBase.Calibrate.cs
@@ -109,6 +109,15 @@ CalibrateResult MakeResult(
throw new InvalidOperationException("Media not loaded");
}
+ // If the drive has multiple partitions, check with the user and break if negative
+ if (_drive.HasInitiatorPartition)
+ {
+ if (!host.Confirm(
+ "Calibrating a multi-partition media will have no effect.\nWould you still like to continue?",
+ defaultAnswer: false))
+ return MakeResult(aborted: true, message: "For calibration, use a single-partition media", mode: request.Mode);
+ }
+
try
{
LogWarn("Calibration is destructive — use a scratch cartridge only");
@@ -362,6 +371,7 @@ public bool AddCalibration(ITapeCalibration calibration)
/// This is a pure convenience for the UI — it doesn't gate New/Resume/Recalibrate, which all remain
/// available regardless of the result, since Resume/Recalibrate will fail gracefully if the cartridge
/// is unsuitable.
+ ///
///
/// Recommendation logic. Resume AND Recalibrate both require a valid ON-TAPE checkpoint — no stored
/// profile can substitute for a checkpoint that is not physically on the cartridge. Recalibrate
@@ -376,6 +386,7 @@ public bool AddCalibration(ITapeCalibration calibration)
/// Complete run, no baseline | true | true | false | Resume
/// Complete run + baseline | true | true | true | Recalibrate
///
+ ///
///
public Task ExecuteInspectCalibrationMediaAsync()
{
@@ -385,7 +396,7 @@ public Task ExecuteInspectCalibrationMediaAsync()
{
try
{
- LogInfo("Starting media inspection for recalibration");
+ LogInfo("Starting media inspection for recalibration...");
if (_drive is null || !_drive.IsMediaLoaded)
{
@@ -398,6 +409,20 @@ public Task ExecuteInspectCalibrationMediaAsync()
};
}
+ // If the drive has multiple partitions, check with the user and break if negative
+ if (_drive.HasInitiatorPartition)
+ {
+ if (!host.Confirm(
+ "Calibrating a multi-partition media will have no effect.\nWould you still like to continue?",
+ defaultAnswer: false))
+ return new InspectCalibrationMediaResult
+ {
+ Success = false,
+ Outcome = ServiceReportLevel.Warning,
+ Message = "For calibration, use a single-partition media",
+ };
+ }
+
var calibrator = new TapeCalibrator(_drive);
TapeCalibrationMediaInfo? info = calibrator.InspectMedia();
@@ -540,6 +565,12 @@ protected int AutoLoadCalibrations()
return 0;
}
+ if (_drive.HasInitiatorPartition)
+ {
+ LogInfoSub("Calibration autoload skipped: multi-partition media");
+ return 0;
+ }
+
try
{
var calibrations = CalibrationStore.LoadAll();
diff --git a/TapeLibNET/TapeDriveWin32Backend.cs b/TapeLibNET/TapeDriveWin32Backend.cs
index dc1cc56..8657160 100644
--- a/TapeLibNET/TapeDriveWin32Backend.cs
+++ b/TapeLibNET/TapeDriveWin32Backend.cs
@@ -141,7 +141,7 @@ public partial class TapeDriveWin32Backend(ILoggerFactory loggerFactory) : TapeD
public override string Product => string.IsNullOrEmpty(m_ltoProduct) ? "[unknown]" : m_ltoProduct;
public override string Revision => m_ltoRevision; // SCSI INQUIRY Product Revision Level; empty when unknown
- public bool IsLto => m_ltoGeneration >= 1;
+ public bool IsLto => m_ltoGeneration >= 0; // FIXME: To experiment with SCSI support on pre-LTO drives, set to 0. Otherwise, to 1
public bool IsLto5Plus => m_ltoGeneration >= 5;
#endregion
diff --git a/TapeWinNET/CalibrationProfilesWindow.xaml b/TapeWinNET/CalibrationProfilesWindow.xaml
index 6eddb62..c1025aa 100644
--- a/TapeWinNET/CalibrationProfilesWindow.xaml
+++ b/TapeWinNET/CalibrationProfilesWindow.xaml
@@ -32,7 +32,7 @@
diff --git a/TapeWinNET/CalibrationWindow.xaml b/TapeWinNET/CalibrationWindow.xaml
index 15e2223..7bbc857 100644
--- a/TapeWinNET/CalibrationWindow.xaml
+++ b/TapeWinNET/CalibrationWindow.xaml
@@ -32,7 +32,7 @@
diff --git a/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs b/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs
index 2ae76c7..e5fd3ee 100644
--- a/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs
+++ b/TapeWinNET/ViewModels/CalibrationProfilesViewModel.cs
@@ -61,7 +61,7 @@ public ITapeCalibration? SelectedProfile
public ICommand RemoveCommand { get; }
private bool CanApply =>
- SelectedProfile is not null && !_isBusy() && _tapeService.IsMediaLoaded;
+ SelectedProfile is not null && !_isBusy() && _tapeService.IsMediaLoaded && !_tapeService.HasInitiatorPartition;
#endregion
diff --git a/TapeWinNET/ViewModels/CalibrationResultViewModel.cs b/TapeWinNET/ViewModels/CalibrationResultViewModel.cs
index e974840..fbd2019 100644
--- a/TapeWinNET/ViewModels/CalibrationResultViewModel.cs
+++ b/TapeWinNET/ViewModels/CalibrationResultViewModel.cs
@@ -27,7 +27,7 @@ public CalibrationResultViewModel(TapeService tapeService, CalibrateResult resul
_onApplied = onApplied;
SaveProfileCommand = new RelayCommand(_ => SaveProfile(), _ => Calibration is not null && !IsSaved);
- ApplyProfileCommand = new RelayCommand(_ => ApplyProfile(), _ => Calibration is not null && !IsApplied);
+ ApplyProfileCommand = new RelayCommand(_ => ApplyProfile(), _ => Calibration is not null && !IsApplied && !_tapeService.HasInitiatorPartition);
if (_result is { RecalibrationVerdict: RecalibrationVerdict.FullRecalibrationAdvised })
RunFullCalibrationCommand = new RelayCommand(_ => RequestFullCalibration());
diff --git a/TapeWinNET/ViewModels/MainViewModel.Calibration.cs b/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
index 1ae357e..efafd84 100644
--- a/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.Calibration.cs
@@ -90,7 +90,7 @@ public bool IsAbortCalibrationEnabled
private void InitializeCalibrationCommands()
{
- CalibrateMediaCommand = new RelayCommand(ShowCalibrationWindow, _ => !IsBusy && _tapeService.IsMediaLoaded);
+ CalibrateMediaCommand = new RelayCommand(ShowCalibrationWindow, _ => !IsBusy && _tapeService.IsMediaLoaded && !_tapeService.HasInitiatorPartition);
AbortCalibrationCommand = new RelayCommand(AbortCalibration, _ => IsCalibrateInProgress);
ShowCalibrationProfilesCommand = new RelayCommand(ShowCalibrationProfilesWindow);
}
From 756b24cd9afa1a2fa283918697aa084705a26d62 Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Sat, 22 Aug 2026 19:09:40 +0200
Subject: [PATCH 29/37] Update the color of "Writable" display to refelct the
remaining capacity percentage.
---
TapeLibNET.Tests/RemoteBackendTests.cs | 2 -
TapeLibNET/TapeCalibration.cs | 32 ++++++++++++
TapeLibNET/TapeCalibrator.cs | 2 +
TapeWinNET/Converters/WarningConverters.cs | 22 ++++++++
TapeWinNET/MainWindow.xaml | 18 +++----
TapeWinNET/Models/LogEntry.cs | 60 ++++++++++++++++++++++
TapeWinNET/Models/PropertyItem.cs | 4 +-
TapeWinNET/ViewModels/MainViewModel.cs | 4 +-
8 files changed, 128 insertions(+), 16 deletions(-)
diff --git a/TapeLibNET.Tests/RemoteBackendTests.cs b/TapeLibNET.Tests/RemoteBackendTests.cs
index 389f502..7e2d1af 100644
--- a/TapeLibNET.Tests/RemoteBackendTests.cs
+++ b/TapeLibNET.Tests/RemoteBackendTests.cs
@@ -33,7 +33,6 @@ protected virtual void EnsureServiceAvailable() { }
#region *** Test Data ***
-#pragma warning disable CA1825
public static TheoryData AllProfiles =>
[
DriveProfile.Setmarks,
@@ -41,7 +40,6 @@ protected virtual void EnsureServiceAvailable() { }
DriveProfile.SeqFilemarks,
DriveProfile.FilemarksOnly,
];
-#pragma warning restore CA1825
#endregion
diff --git a/TapeLibNET/TapeCalibration.cs b/TapeLibNET/TapeCalibration.cs
index c90ffec..3eb7b7c 100644
--- a/TapeLibNET/TapeCalibration.cs
+++ b/TapeLibNET/TapeCalibration.cs
@@ -415,6 +415,38 @@ public static string CapacityBucket(long capacityBytes)
return $"{(long)(Math.Round(value / mag) * mag)}{(useMB ? "MB" : "GB")}";
}
+#if ALTERNATIVE_VERSION_WITH_ROUNDING
+ public static string CapacityBucket(long capacityBytes)
+ {
+ if (capacityBytes <= 0)
+ return "0";
+
+ const long bytesPerMB = 1024L * 1024;
+ bool useMB = capacityBytes < 2L * c_bytesPerGB;
+ double value = capacityBytes / (double)(useMB ? bytesPerMB : c_bytesPerGB);
+
+ // Base granularity: nearest 10^(floor(log10)-1) keeps 2 significant figures.
+ double step = Math.Pow(10, Math.Floor(Math.Log10(value)) - 1);
+ if (step < 1) step = 1;
+
+ double fine = Math.Round(value / step) * step; // 2 sig figs (default)
+ double coarse = Math.Round(value / (step * 10)) * (step * 10); // trailing sig fig -> 0
+
+ // Snap to the rounder label only when it stays within relative tolerance.
+ double chosen = (coarse > 0 && Math.Abs(coarse - value) <= c_bucketSnapTolerance * value)
+ ? coarse
+ : fine;
+
+ return $"{(long)chosen}{(useMB ? "MB" : "GB")}";
+ }
+
+ ///
+ /// Relative jitter a capacity may show before it counts as a distinct bucket.
+ /// ~2%: snaps 79.2 GB to 80GB, keeps 76 GB at 76GB, keeps 780 GB at 780GB.
+ ///
+ private const double c_bucketSnapTolerance = 0.02;
+#endif
+
#endregion
#region *** Persistence (JSON) ***
diff --git a/TapeLibNET/TapeCalibrator.cs b/TapeLibNET/TapeCalibrator.cs
index 726a551..39a2d49 100644
--- a/TapeLibNET/TapeCalibrator.cs
+++ b/TapeLibNET/TapeCalibrator.cs
@@ -31,6 +31,7 @@ public readonly record struct TapeCalibrationProgress(
/// ReportedRemaining against the true bytes-written, and captures the EW landmark. Produces an
/// the application can persist and later hand to
/// .
+///
///
/// Sampling is TWO-PHASE (see ): a coarse BODY across most of the
/// medium, then a fine TAIL over the EW → EOM region (entered at physical EW or the last few percent
@@ -53,6 +54,7 @@ public readonly record struct TapeCalibrationProgress(
/// via . This class does NOT judge drive-profile matching — that is the
/// caller's / service layer's responsibility.
///
+///
///
public sealed class TapeCalibrator(TapeDrive drive) : TapeDriveHolder(drive)
{
diff --git a/TapeWinNET/Converters/WarningConverters.cs b/TapeWinNET/Converters/WarningConverters.cs
index fb34ea0..3f1465a 100644
--- a/TapeWinNET/Converters/WarningConverters.cs
+++ b/TapeWinNET/Converters/WarningConverters.cs
@@ -1,5 +1,7 @@
using System.Globalization;
+using System.Windows;
using System.Windows.Data;
+using System.Windows.Media;
using TapeWinNET.Models;
namespace TapeWinNET.Converters;
@@ -23,6 +25,26 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu
}
}
+///
+/// Converts a to its foreground brush.
+/// Usage: Foreground="{Binding HighlightLevel, Converter={x:Static converters:WarningLevelToBrushConverter.Instance}}"
+///
+public class WarningLevelToBrushConverter : IValueConverter
+{
+ public static WarningLevelToBrushConverter Instance { get; } = new();
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ // Return the brush, or UnsetValue so None keeps the inherited Foreground.
+ return value is WarningLevel level && WarningLevelHelper.GetBrush(level) is Brush brush
+ ? brush
+ : DependencyProperty.UnsetValue;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => throw new NotImplementedException();
+}
+
///
/// Formats a display string with an optional timestamp.
/// Values[0] = , Values[1] = bool ShowTimestamps.
diff --git a/TapeWinNET/MainWindow.xaml b/TapeWinNET/MainWindow.xaml
index 35d90c2..62cf485 100644
--- a/TapeWinNET/MainWindow.xaml
+++ b/TapeWinNET/MainWindow.xaml
@@ -611,16 +611,14 @@
-
-
-
-
+
+
+
+
diff --git a/TapeWinNET/Models/LogEntry.cs b/TapeWinNET/Models/LogEntry.cs
index f90fdde..9201182 100644
--- a/TapeWinNET/Models/LogEntry.cs
+++ b/TapeWinNET/Models/LogEntry.cs
@@ -1,6 +1,9 @@
// WarningLevel is an alias for ServiceReportLevel — same enum, single definition in TapeLibNET.
global using WarningLevel = TapeLibNET.Services.ServiceReportLevel;
+using System.Windows;
+using System.Windows.Media;
+
namespace TapeWinNET.Models;
///
@@ -49,4 +52,61 @@ public static class WarningLevelHelper
WarningLevel.Completed => "✓",
_ => string.Empty
};
+
+ ///
+ /// Returns the application-defined forderground brush for the given warning level.
+ ///
+ /// The warning level
+ /// The corresponding brush loaded from the application resource; null if not found
+ public static Brush? GetBrush(WarningLevel level)
+ {
+ var key = level switch
+ {
+ WarningLevel.Info => "WarningFg.Info",
+ WarningLevel.Completed => "WarningFg.Completed",
+ WarningLevel.Warning => "WarningFg.Warning",
+ WarningLevel.Failed => "WarningFg.Failed",
+ WarningLevel.Error => "WarningFg.Error",
+ _ => null, // None → no override
+ };
+
+ return key is null ? null : Application.Current.TryFindResource(key) as Brush;
+ }
+
+ ///
+ /// Translates a normalized double value into a based on severity thresholds.
+ ///
+ /// A normalized value representing a percentage (typically between 0.0 and 1.0).
+ /// The corresponding based on the threshold ranges.
+ ///
+ /// The mapping is evaluated sequentially as follows:
+ ///
+ /// Values <= 0.025 (2.5%) map to .
+ /// Values <= 0.05 (5.0%) map to .
+ /// Values <= 0.25 (25.0%) map to .
+ /// Values <= 0.50 (50.0%) map to .
+ /// Values <= 1.00 (100.0%) map to .
+ /// Any values greater than 1.00 map to .
+ ///
+ ///
+ public static WarningLevel Translate(double percentage) => percentage switch
+ {
+ <= 0.025 => WarningLevel.Error,
+ <= 0.05 => WarningLevel.Failed,
+ <= 0.25 => WarningLevel.Warning,
+ <= 0.50 => WarningLevel.Completed,
+ <= 1.00 => WarningLevel.Info,
+ _ => WarningLevel.None // Discard pattern handles any value > 1.0 (or negative inputs)
+ };
+
+ ///
+ /// Translates an integer percentage value (0 to 100) into a .
+ ///
+ /// An integer percentage value (typically 0 to 100).
+ /// The corresponding .
+ ///
+ /// This method converts the integer to a normalized double (0.0 to 1.0) and delegates to .
+ ///
+ public static WarningLevel Translate(int percentage) => Translate(percentage / 100.0);
+
}
diff --git a/TapeWinNET/Models/PropertyItem.cs b/TapeWinNET/Models/PropertyItem.cs
index 9da780e..a365bed 100644
--- a/TapeWinNET/Models/PropertyItem.cs
+++ b/TapeWinNET/Models/PropertyItem.cs
@@ -4,7 +4,7 @@ namespace TapeWinNET.Models;
/// Represents a property-value pair for display in the ListView.
/// Used for Drive Information and Media Information views.
///
-public class PropertyItem(string property, string value, bool isHighlighted = false)
+public class PropertyItem(string property, string value, WarningLevel highlightLevel = WarningLevel.None)
{
public string Property { get; } = property;
public string Value { get; } = value;
@@ -13,5 +13,5 @@ public class PropertyItem(string property, string value, bool isHighlighted = fa
/// When true, the row is displayed in warning color (e.g. red foreground).
/// Used for TOC-from-file indicator and similar warnings.
///
- public bool IsHighlighted { get; } = isHighlighted;
+ public WarningLevel HighlightLevel { get; } = highlightLevel;
}
\ No newline at end of file
diff --git a/TapeWinNET/ViewModels/MainViewModel.cs b/TapeWinNET/ViewModels/MainViewModel.cs
index 74c87a9..bcd6f1f 100644
--- a/TapeWinNET/ViewModels/MainViewModel.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.cs
@@ -1550,7 +1550,7 @@ static string pair(long reported, long estimated)
// The headline figure — highlighted because it is the one the user plans a backup against.
PropertyList.Add(new PropertyItem("Writable",
Helpers.BytesToStringLong(_tapeService.WritableRemaining),
- isHighlighted: true));
+ highlightLevel: WarningLevelHelper.Translate(_tapeService.WritableRemaining / (double)_tapeService.EstimatedCapacity)));
PropertyList.Add(new PropertyItem("Estimation by", _tapeService.RemainingEstimationSource));
}
@@ -1582,7 +1582,7 @@ private void LoadMediaInfo()
_tapeService.IsTOCFromFile
? $"File: {_tapeService.TOCFilePath}"
: _tapeService.HasInitiatorPartition ? "Partition" : "Set",
- isHighlighted: _tapeService.IsTOCFromFile));
+ highlightLevel: _tapeService.IsTOCFromFile? WarningLevel.Warning : WarningLevel.None));
PropertyList.Add(new PropertyItem("Volume", $"#{toc.Volume}"));
PropertyList.Add(new PropertyItem("Continued on Next Volume",
toc.ContinuedOnNextVolume ? "Yes" : "No"));
From 8701664f45abb5a50115b18457090747464a7601 Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Sun, 23 Aug 2026 03:09:55 +0200
Subject: [PATCH 30/37] Update TapeWinNET toolbar tape drive icons: add drive
number and optional remote badges.
---
TapeWinNET/MainWindow.xaml | 11 +-
TapeWinNET/TapeIcons.cs | 163 ++++++++++++++++--
TapeWinNET/ViewModels/MainViewModel.Remote.cs | 8 +-
TapeWinNET/ViewModels/MainViewModel.cs | 26 +--
4 files changed, 165 insertions(+), 43 deletions(-)
diff --git a/TapeWinNET/MainWindow.xaml b/TapeWinNET/MainWindow.xaml
index 62cf485..32fda82 100644
--- a/TapeWinNET/MainWindow.xaml
+++ b/TapeWinNET/MainWindow.xaml
@@ -367,8 +367,7 @@
Visibility="{Binding IsRemoteConnected,
Converter={StaticResource InverseBoolToVis}}">
@@ -386,8 +385,7 @@
Style="{DynamicResource {x:Static ToolBar.ButtonStyleKey}}"
Width="30" Height="28" Padding="4,2"
Margin="0,0,1,0">
-
@@ -408,7 +406,7 @@
Visibility="{Binding IsRemoteConnected,
Converter={StaticResource BoolToVis}}">
+ Icon: tape-drive with globe badge (Icon property, set in MainViewModel.ProbeRemoteDrivesAsync()). -->
@@ -426,8 +424,7 @@
Style="{DynamicResource {x:Static ToolBar.ButtonStyleKey}}"
Width="30" Height="28" Padding="4,2"
Margin="0,0,1,0">
-
diff --git a/TapeWinNET/TapeIcons.cs b/TapeWinNET/TapeIcons.cs
index b29bb69..d45f60a 100644
--- a/TapeWinNET/TapeIcons.cs
+++ b/TapeWinNET/TapeIcons.cs
@@ -1,19 +1,16 @@
-using System.Windows;
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Runtime.InteropServices;
+using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Media.TextFormatting;
-
-using System.Collections.Concurrent;
-using System.Globalization;
-
+using TapeWinNET.Models;
+using TapeWinNET.Utils;
using Windows.Win32;
-using Windows.Win32.UI.Shell;
-
-using System.Runtime.InteropServices;
using Windows.Win32.System.SystemServices;
-
-using TapeWinNET.Models;
+using Windows.Win32.UI.Shell;
namespace TapeWinNET;
@@ -90,7 +87,7 @@ private static RenderTargetBitmap ResizeImageSource(BitmapSource source, int wid
}
}
-// helpers to extract tape drive related icons
+/// Helpers to extract tape drive related icons
public static class TapeIcons
{
private static readonly Guid GUID_DEVCLASS_TAPEDRIVE =
@@ -115,8 +112,89 @@ public static class TapeIcons
{
return IconLoader.LoadStockIcon(SHSTOCKICONID.SIID_DOCNOASSOC, large);
}
+
+ // Caches key on (number, large): the two sizes render different bitmaps
+ private static readonly Dictionary<(int number, bool large), ImageSource> _numberedDriveIcons = [];
+ private static readonly Dictionary<(int number, bool large), ImageSource> _numberedRemoteDriveIcons = [];
+ private static readonly Dictionary _remoteDriveIcons = [];
+
+ /// Tape drive with a drive number badge (subscript, bottom-right).
+ public static ImageSource? GetNumberedTapeDriveIcon(int number, bool large = false)
+ {
+ if (_numberedDriveIcons.TryGetValue((number, large), out var cached))
+ return cached;
+
+ var baseIcon = GetTapeDriveIcon(large);
+ if (baseIcon is null)
+ return null;
+
+ var badge = IconComposer.RenderDigitBadge(number); // already frozen
+ var composed = IconComposer.ComposeWithOverlay(baseIcon, badge); // freezes result
+ _numberedDriveIcons[(number, large)] = composed;
+ return composed;
+ }
+
+ // Globe overlay — blue glyph on a white disc, rendered once for legibility
+ private static BitmapSource? _globeOverlay;
+
+ private static BitmapSource? GlobeOverlay =>
+ _globeOverlay ??= CreateGlobeOverlay();
+
+ private static BitmapSource? CreateGlobeOverlay()
+ {
+ var overlay = IconComposer.RenderGlyph(
+ ToolbarIconHelper.GlyphConnectRemote,
+ pixelSize: 32, // 4× target for a crisp downscale
+ color: Color.FromRgb(0, 80, 160),
+ backgroundColor: Colors.White);
+ overlay?.Freeze();
+ return overlay;
+ }
+
+ /// Tape drive with a globe badge (superscript, top-right).
+ public static ImageSource? GetRemoteTapeDriveIcon(bool large = false)
+ {
+ if (_remoteDriveIcons.TryGetValue(large, out var cached))
+ return cached;
+
+ var baseIcon = GetTapeDriveIcon(large);
+ if (baseIcon is null || GlobeOverlay is null)
+ return null;
+
+ // Globe rides top-right, leaving the bottom-right slot free for a number
+ var composed = IconComposer.ComposeWithOverlay(
+ baseIcon, GlobeOverlay, OverlayCorner.TopRight);
+ _remoteDriveIcons[large] = composed;
+ return composed;
+ }
+
+ /// Tape drive with globe (superscript) + drive number (subscript).
+ public static ImageSource? GetNumberedRemoteTapeDriveIcon(int number, bool large = false)
+ {
+ if (_numberedRemoteDriveIcons.TryGetValue((number, large), out var cached))
+ return cached;
+
+ var baseIcon = GetTapeDriveIcon(large);
+ if (baseIcon is null || GlobeOverlay is null)
+ return null;
+
+ // 1. Globe superscript (top-right)
+ var withGlobe = IconComposer.ComposeWithOverlay(
+ baseIcon, GlobeOverlay, OverlayCorner.TopRight);
+
+ // 2. Number subscript (bottom-right)
+ var badge = IconComposer.RenderDigitBadge(number);
+ var composed = IconComposer.ComposeWithOverlay(
+ withGlobe, badge, OverlayCorner.BottomRight);
+
+ _numberedRemoteDriveIcons[(number, large)] = composed;
+ return composed;
+ }
}
+/// Corner of the output canvas where an overlay badge sits.
+public enum OverlayCorner { TopLeft, TopRight, BottomLeft, BottomRight }
+
///
/// Utilities for composing toolbar icons from a main image and a small overlay badge.
///
@@ -172,7 +250,7 @@ internal static class IconComposer
}
// Center the glyph within the square
- double x = (pixelSize - text.Width) / 2.0;
+ double x = (pixelSize - text.Width) / 2.0;
double y = (pixelSize - text.Height) / 2.0;
ctx.DrawText(text, new Point(x, y));
}
@@ -186,14 +264,55 @@ internal static class IconComposer
}
}
+ public static BitmapSource RenderDigitBadge(
+ int digit,
+ int pixelSize = 32,
+ Color? discColor = null,
+ Color? textColor = null)
+ {
+ discColor ??= Color.FromRgb(0, 80, 160); // same blue as the globe
+ textColor ??= Colors.White;
+
+ var dv = new DrawingVisual();
+ using (var dc = dv.RenderOpen())
+ {
+ double r = pixelSize / 2.0;
+ var center = new Point(r, r);
+
+ // Filled disc backdrop — reads as a count badge, legible on any icon
+ dc.DrawEllipse(new SolidColorBrush(discColor.Value), null, center, r, r);
+
+ var ft = new FormattedText(
+ digit.ToString(CultureInfo.InvariantCulture),
+ CultureInfo.InvariantCulture,
+ FlowDirection.LeftToRight,
+ new Typeface(new FontFamily("Segoe UI"),
+ FontStyles.Normal, FontWeights.Bold, FontStretches.Normal),
+ pixelSize * 0.72, // fill most of the disc
+ new SolidColorBrush(textColor.Value),
+ 1.0)
+ { TextAlignment = TextAlignment.Center };
+
+ // x = r centers horizontally; shift up by half the glyph height
+ dc.DrawText(ft, new Point(r, r - ft.Height / 2.0));
+ }
+
+ var rtb = new RenderTargetBitmap(pixelSize, pixelSize, 96, 96, PixelFormats.Pbgra32);
+ rtb.Render(dv);
+ rtb.Freeze();
+ return rtb;
+ }
+
///
/// Composes a icon with a smaller
- /// badge placed in its lower-right corner.
+ /// badge placed in the specified (default lower-right).
///
/// The base icon.
/// The badge icon, scaled to
/// of the output canvas.
- /// Fraction of the canvas size used for the overlay (default 0.45).
+ /// Which corner receives the badge. Defaults to
+ /// to preserve existing single-badge behavior.
+ /// Fraction of the canvas size used for the overlay (default 0.5).
///
/// Pixel dimensions of the output bitmap (default 32). Rendering at 2× the display size
/// (the element will use Width/Height=16) gives WPF enough
@@ -204,15 +323,21 @@ internal static class IconComposer
/// or unchanged if is .
///
public static BitmapSource ComposeWithOverlay(BitmapSource main, BitmapSource? overlay,
+ OverlayCorner corner = OverlayCorner.BottomRight,
double overlayFraction = 0.5, int outputSize = 32)
{
if (overlay is null)
return main;
- // Overlay size and position — lower-right corner of the output canvas
+ // Overlay size, then position it in the requested corner of the output canvas
double oSize = Math.Round(outputSize * overlayFraction);
- double oX = outputSize - oSize;
- double oY = outputSize - oSize;
+
+ double oX = corner is OverlayCorner.TopRight or OverlayCorner.BottomRight
+ ? outputSize - oSize
+ : 0;
+ double oY = corner is OverlayCorner.BottomLeft or OverlayCorner.BottomRight
+ ? outputSize - oSize
+ : 0;
var target = new RenderTargetBitmap(outputSize, outputSize, 96, 96, PixelFormats.Pbgra32);
var visual = new DrawingVisual();
@@ -220,8 +345,8 @@ public static BitmapSource ComposeWithOverlay(BitmapSource main, BitmapSource? o
using (var ctx = visual.RenderOpen())
{
// Scale main icon to fill the entire canvas (2× for a 16px source → crisp downscale)
- ctx.DrawImage(main, new Rect(0, 0, outputSize, outputSize));
- ctx.DrawImage(overlay, new Rect(oX, oY, oSize, oSize));
+ ctx.DrawImage(main, new Rect(0, 0, outputSize, outputSize));
+ ctx.DrawImage(overlay, new Rect(oX, oY, oSize, oSize));
}
target.Render(visual);
diff --git a/TapeWinNET/ViewModels/MainViewModel.Remote.cs b/TapeWinNET/ViewModels/MainViewModel.Remote.cs
index 0b1c30b..a788f50 100644
--- a/TapeWinNET/ViewModels/MainViewModel.Remote.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.Remote.cs
@@ -159,14 +159,14 @@ private void BuildInitialRemoteSubmenu()
var drive0Item = new DriveMenuItem("Drive _0", 0, OpenRemoteDriveCommand);
RemoteDriveMenuItems.Add(drive0Item);
- ToolbarRemoteDriveItems.Add(drive0Item); // mirrored to toolbar
+ ToolbarRemoteDriveItems.Add(drive0Item with { Icon = TapeIcons.GetNumberedRemoteTapeDriveIcon(0) }); // mirrored to toolbar
RemoteDriveMenuItems.Add(new DriveMenuItem("Scanning drives…", RemoteScanningNumber, OpenRemoteDriveCommand));
- RemoteDriveMenuItems.Add(new DriveMenuItem("_Specify...", RemoteSpecifyDriveNumber, OpenRemoteDriveCommand));
+ RemoteDriveMenuItems.Add(new DriveMenuItem("_Specify...", RemoteSpecifyDriveNumber, OpenRemoteDriveCommand));
RemoteDriveMenuItems.Add(new Separator());
RemoteDriveMenuItems.Add(new DriveMenuItem("_Open Remote Virtual Drive...", 0, OpenRemoteVirtualDriveCommand));
RemoteDriveMenuItems.Add(new Separator());
- RemoteDriveMenuItems.Add(new DriveMenuItem("_Disconnect", 0, DisconnectRemoteHostCommand));
+ RemoteDriveMenuItems.Add(new DriveMenuItem("_Disconnect", 0, DisconnectRemoteHostCommand));
}
///
@@ -216,7 +216,7 @@ private async Task ProbeRemoteDrivesAsync(RemoteHostSettings settings)
{
var driveItem = new DriveMenuItem($"Drive _{driveNum}", (int)driveNum, OpenRemoteDriveCommand);
RemoteDriveMenuItems.Insert(insertAt, driveItem);
- ToolbarRemoteDriveItems.Add(driveItem); // mirror to toolbar
+ ToolbarRemoteDriveItems.Add(driveItem with { Icon = TapeIcons.GetNumberedRemoteTapeDriveIcon((int)driveNum) }); // mirror to toolbar
insertAt++;
}
}
diff --git a/TapeWinNET/ViewModels/MainViewModel.cs b/TapeWinNET/ViewModels/MainViewModel.cs
index bcd6f1f..b6a820c 100644
--- a/TapeWinNET/ViewModels/MainViewModel.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.cs
@@ -1,22 +1,19 @@
-using System.Collections.ObjectModel;
+using FclNET;
+using System.Collections.ObjectModel;
using System.IO;
using System.Windows;
using System.Windows.Input;
+using System.Windows.Media;
using System.Windows.Threading;
-
-using Windows.Win32.System.SystemServices; // for Helpers
-
using TapeLibNET;
-using TapeLibNET.Virtual;
using TapeLibNET.Services;
-using TapeWinNET.Converters;
-
-using FclNET;
-
+using TapeLibNET.Virtual;
using TapeWinNET.Controls;
+using TapeWinNET.Converters;
using TapeWinNET.Models;
using TapeWinNET.Services;
using TapeWinNET.Utils;
+using Windows.Win32.System.SystemServices; // for Helpers
namespace TapeWinNET.ViewModels;
@@ -37,7 +34,10 @@ public enum ContentPaneType
///
/// Represents a menu item for opening a specific tape drive.
///
-public record DriveMenuItem(string Header, int DriveNumber, ICommand Command);
+public record DriveMenuItem(string Header, int DriveNumber, ICommand Command)
+{
+ public ImageSource? Icon { get; init; } // init suffices for `with`
+}
public partial class MainViewModel : ViewModelBase
{
@@ -141,7 +141,7 @@ private void InitializeDriveMenu()
DriveNumber: 0,
Command: OpenDriveCommand);
DriveMenuItems.Add(drive0Item);
- ToolbarDriveItems.Add(drive0Item/* with { Header = "Drive 0" }*/); // mirrored — toolbar excludes "Specify..."
+ ToolbarDriveItems.Add(drive0Item with { Icon = TapeIcons.GetNumberedTapeDriveIcon(0) });
// "Specify..." lets the user enter a device name directly (menu only, not toolbar)
DriveMenuItems.Add(new DriveMenuItem(
@@ -166,8 +166,8 @@ private void InitializeDriveMenu()
DriveNumber: driveNum,
Command: OpenDriveCommand);
DriveMenuItems.Insert(insertIndex, driveItem);
- // Mirror to toolbar: insert at end (all physical drives are appended)
- ToolbarDriveItems.Add(driveItem/* with { Header = $"Drive {driveNum}" }*/);
+ // Mirror to toolbar with the numbered badge
+ ToolbarDriveItems.Add(driveItem with { Icon = TapeIcons.GetNumberedTapeDriveIcon(driveNum) });
});
}
}
From fcd70c0516f193a0efa516a8fa1ccf0f0374d5f6 Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Mon, 24 Aug 2026 04:55:22 +0200
Subject: [PATCH 31/37] Adjust tape drive calibration / reported remaining and
early warning mechnism based on the actual test results. Introduce
"ReportsExactRemaining" backend property for use with virtual drives. Update
EW design doc.
---
.../CalibrationAndLogicalEwTests.cs | 51 ++++
.../Remote/RemoteServiceMultiVolumeTests.cs | 6 +-
.../Services/ServiceCalibrationResumeTests.cs | 30 ++-
.../Services/ServiceMultiVolumeTests.cs | 3 +-
TapeLibNET/Remote/RemoteTapeDriveBackend.cs | 2 +
TapeLibNET/Remote/TapeDrive.proto | 2 +
TapeLibNET/TapeCalibration.cs | 133 ++++++----
TapeLibNET/TapeDrive.cs | 75 ++++--
TapeLibNET/TapeDriveBackend.cs | 11 +-
TapeLibNET/TapeDriveWin32Backend.cs | 1 +
.../Virtual/VirtualTapeDriveBackend.EW.cs | 3 +
TapeServiceNET/TapeDriveGrpcService.cs | 3 +
TapeServiceNET/TempVirtualTapeDriveBackend.cs | 1 +
docs/Design-RemainingAndEw.md | 235 +++++++++++-------
14 files changed, 387 insertions(+), 169 deletions(-)
diff --git a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
index a0b8026..f5ed193 100644
--- a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
+++ b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
@@ -312,6 +312,7 @@ public void LogicalEw_BeforePhysicalEw_FiresFromCurveWithLargeReserve()
Assert.True(firedBeforePhysical, "Logical EW should trip from the curve before the physical EW zone");
}
+ /*
[Fact]
public void LogicalEw_AfterPhysicalEw_FiresFromByteCountWithSmallReserve()
{
@@ -355,6 +356,56 @@ public void LogicalEw_AfterPhysicalEw_FiresFromByteCountWithSmallReserve()
Assert.True(sawPhysicalEwBeforeLogical,
"With a tiny reserve, logical EW should fire only after the physical EW landmark");
}
+ */
+
+ [Fact]
+ public void LogicalEw_AfterPhysicalEw_FiresFromByteCountWithSmallReserve()
+ {
+ // Measure a real calibration first, so the loaded curve accurately models the emulated drive
+ // (a small, true over-report margin). The pessimistic A-PRIORI cannot be used here: on this tiny
+ // 64 MB cartridge its margin FLOOR (8 MB = 12.5%) dwarfs the 4% physical-EW zone (2.56 MB), so the
+ // curve trips logical EW well before physical EW is ever seen — correct pessimism, but not the
+ // after-EW byte-count regime under test.
+ var (calDrive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ ITapeCalibration? cal = new TapeCalibrator(calDrive)
+ {
+ Options = new TapeCalibrationOptions { SampleCount = 60 },
+ }.Run();
+ Assert.NotNull(cal);
+
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+
+ // A SMALL reserve so logical EW only trips in the precise after-physical-EW byte-count regime.
+ long reserve = 256L * 1024;
+ Assert.True(drive.AddCalibration(cal!));
+ Assert.True(drive.SetEarlyWarning(reserve));
+
+ int block = (int)drive.MaximumBlockSize;
+ var data = IncompressibleBlock(block, seed: 12);
+
+ Assert.True(drive.MoveToPartition(MediaPartition.Content));
+ Assert.True(drive.Rewind());
+
+ bool sawPhysicalEwBeforeLogical = false;
+ bool physicalSeen = false;
+ while (true)
+ {
+ int n = drive.WriteDirect(data, 0, block, out _, out bool ew, out bool eom);
+ physicalSeen |= drive.IsPhysicalEarlyWarningSeen;
+
+ if (ew)
+ {
+ sawPhysicalEwBeforeLogical = physicalSeen;
+ break;
+ }
+ if (eom || n == 0)
+ break;
+ }
+
+ Assert.True(drive.IsEarlyWarning, "Logical EW should have fired near the tail");
+ Assert.True(sawPhysicalEwBeforeLogical,
+ "With a tiny reserve, logical EW should fire only after the physical EW landmark");
+ }
#endregion
diff --git a/TapeLibNET.Tests/Services/Remote/RemoteServiceMultiVolumeTests.cs b/TapeLibNET.Tests/Services/Remote/RemoteServiceMultiVolumeTests.cs
index eb2f484..93f5cfb 100644
--- a/TapeLibNET.Tests/Services/Remote/RemoteServiceMultiVolumeTests.cs
+++ b/TapeLibNET.Tests/Services/Remote/RemoteServiceMultiVolumeTests.cs
@@ -35,9 +35,8 @@ public class RemoteServiceMultiVolumeTests(LocalHostTapeServiceFixture fixture)
///
/// Content-partition capacity for setmarks multi-volume test volumes (36 MiB).
/// TOC reserve is 32 MiB → 4 MiB usable; total content ~5.6 MiB overflows trivially.
- /// +5% capacity (1.8 MiB) for EW estimation margin → 38 MiB
///
- private const long MultiVolumeCapacity_Setmarks = 38L * 1024 * 1024;
+ private const long MultiVolumeCapacity_Setmarks = 36L * 1024 * 1024;
///
/// Content-partition capacity for initiator-partition multi-volume test volumes (3 MiB).
@@ -365,10 +364,9 @@ await svc2.OpenRemoteVirtualFileAsync(
/// Capacity used for the catalog-driven test volumes: matches
/// (36 MiB) so that the setmarks TOC overhead (32 MiB) leaves 4 MiB of usable data space, forcing
/// at least one volume swap when writing 16 × 350 KiB files (~5.6 MiB total).
- /// +5% capacity (1.8 MiB) for EW estimation margin → 38 MiB
/// No initiator partition — CreateTempVirtual does not support one.
///
- private const long CatalogDrivenVolumeCapacity = 38L * 1024 * 1024;
+ private const long CatalogDrivenVolumeCapacity = 36L * 1024 * 1024;
// ── 8.10: catalog-driven multi-volume backup + restore ────────────────────
diff --git a/TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs b/TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs
index 9cf1d57..eaac035 100644
--- a/TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs
+++ b/TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs
@@ -213,11 +213,31 @@ public async Task ExecuteCalibrateAsync_Recalibrate_WithoutExistingOrTrail_Fails
// needing only public API. The drive-level profile-swap test proves the calibrator itself surfaces
// a real behavior shift as a large delta.
- private static ITapeCalibration StaleBaseline(TapeServiceBase service) =>
- // EW→EOM ≈ 22 MB and capacity/phantom both off — comfortably past every recalibration tolerance
- // versus the drive's actual ~2.5 MB EW→EOM on the emulated LTO-4 profile.
- TapeCalibration.Apriori(service.DriveProfileKey, CalibrationCapacity,
- marginPercent: 5.0, remainingAtEwPercent: 40.0);
+ private static TapeCalibration StaleBaseline(TapeServiceBase service)
+ {
+ // Hand-craft a plausible PRE-firmware-change baseline via the public FromMeasurements factory:
+ // a normal measured-shape calibration whose EW→EOM distance (~22 MB) is an order of magnitude
+ // larger than the drive's actual ~2.5 MB, so recalibration's EW-shift comfortably breaches every
+ // service tolerance and drives FullRecalibrationAdvised.
+ // (Replaces the removed Apriori(marginPercent, remainingAtEwPercent) overload the test abused.)
+ const long capacityActual = CalibrationCapacity; // 64 MB — matches the emulated drive
+ const long ewToEomDistance = 22L * 1024 * 1024; // 22 MB, vs the drive's ~2.5 MB
+ const long reportedAtBom = capacityActual; // truthful BOM (no boost)
+
+ // Samples as (ActualWritten, ReportedRemaining): BOM, the EW landmark, hard EOM.
+ var samples = new List<(long ActualWritten, long ReportedRemaining)>
+ {
+ (0L, reportedAtBom), // BOM
+ (capacityActual - ewToEomDistance, ewToEomDistance), // EW landmark (42 MB written)
+ (capacityActual, 0L), // hard EOM
+ };
+
+ (long ActualWritten, long ReportedRemaining) ew =
+ (capacityActual - ewToEomDistance, ewToEomDistance);
+
+ return TapeCalibration.FromMeasurements(
+ service.DriveProfileKey, reportedAtBom, capacityActual, samples, ew);
+ }
[Fact]
public async Task ExecuteCalibrateAsync_Recalibrate_Breach_Confirmed_ChainsFullRun()
diff --git a/TapeLibNET.Tests/Services/ServiceMultiVolumeTests.cs b/TapeLibNET.Tests/Services/ServiceMultiVolumeTests.cs
index 5bc86e7..89d1f93 100644
--- a/TapeLibNET.Tests/Services/ServiceMultiVolumeTests.cs
+++ b/TapeLibNET.Tests/Services/ServiceMultiVolumeTests.cs
@@ -37,9 +37,8 @@ public class ServiceMultiVolumeTests : ServiceTestBase
/// Must be larger than (32 MiB) because
/// the backup agent reserves that space for the in-tape TOC on setmarks drives.
/// 36 MiB → 4 MiB usable per volume; 22 MiB total content overflows trivially.
- /// +5% capacity (1.8 MiB) for EW estimation margin → 38 MiB
///
- private const long MultiVolumeCapacity_Setmarks = 38L * 1024 * 1024;
+ private const long MultiVolumeCapacity_Setmarks = 36L * 1024 * 1024;
///
/// Content-partition capacity for initiator-partition multi-volume test volumes.
diff --git a/TapeLibNET/Remote/RemoteTapeDriveBackend.cs b/TapeLibNET/Remote/RemoteTapeDriveBackend.cs
index 0b62658..465013d 100644
--- a/TapeLibNET/Remote/RemoteTapeDriveBackend.cs
+++ b/TapeLibNET/Remote/RemoteTapeDriveBackend.cs
@@ -185,6 +185,7 @@ private void SyncError(ErrorInfo? error)
public override string Revision => _state.Revision ?? string.Empty;
public bool IsLto => _state.IsLto;
public bool IsLto5Plus => _state.IsLto5Plus;
+ public int LtoGeneration => _state.LtoGeneration;
public override uint DriveNumber => _state.DriveNumber;
#endregion
@@ -202,6 +203,7 @@ private void SyncError(ErrorInfo? error)
public override bool HasInitiatorPartition => _state.HasInitiatorPartition;
public override bool SupportsSetmarks => _state.SupportsSetmarks;
public override bool SupportsSeqFilemarks => _state.SupportsSeqFilemarks;
+ public override bool ReportsExactRemaining => _state.ReportsExactRemaining;
#endregion
diff --git a/TapeLibNET/Remote/TapeDrive.proto b/TapeLibNET/Remote/TapeDrive.proto
index 0a8322b..e9126a8 100644
--- a/TapeLibNET/Remote/TapeDrive.proto
+++ b/TapeLibNET/Remote/TapeDrive.proto
@@ -128,6 +128,8 @@ message BackendState {
bool is_lto = 18;
bool is_lto5plus = 19;
string revision = 20;
+ int32 lto_generation = 21;
+ bool reports_exact_remaining = 22;
}
// ============================================================================
diff --git a/TapeLibNET/TapeCalibration.cs b/TapeLibNET/TapeCalibration.cs
index 3eb7b7c..72db399 100644
--- a/TapeLibNET/TapeCalibration.cs
+++ b/TapeLibNET/TapeCalibration.cs
@@ -145,6 +145,30 @@ private TapeCalibration(
LtoRemainingCurve = ltoRemainingCurve;
}
+ ///
+ /// Builds an IDENTITY baseline: actual remaining == reported remaining everywhere, with NO margin and NO
+ /// EW landmark. For a backend that reports EXACT capacity (a virtual drive with no EW emulation), so the
+ /// estimator neither compensates for over-report nor holds any pessimistic buffer — the logical-EW reserve
+ /// then fires precisely when reported drops to the requested TOC size, and the "space remaining" figure is
+ /// the honest truth. NOT for real hardware, which always over- or under-reports (use ).
+ ///
+ /// Synthesized per session, never persisted — its FormatId is internal-only.
+ public static ITapeCalibration Ideal(string profileKey, long capacity)
+ {
+ if (capacity < 0) capacity = 0;
+
+ // Identity curve: actual == reported at both anchors ⇒ TranslateReportedToActual is the identity.
+ var curve = new List
+ {
+ new(0L, 0L),
+ new(capacity, capacity),
+ };
+
+ // No EW landmark: an honest drive has no phantom/collapse tail to byte-count against.
+ return new TapeCalibration(
+ "tapelibnet-cal-ideal/2", profileKey, capacity, /*phantom*/ 0L, /*capacityActual*/ capacity, curve, null);
+ }
+
///
/// Builds a calibration from a completed run. Raw samples are (ActualWritten, ReportedRemaining)
/// captured while writing; they are transformed here into the ReportedRemaining → ActualRemaining
@@ -226,65 +250,82 @@ public static TapeCalibration FromMeasurements(
}
///
- /// Builds a blind-guess baseline calibration (no run required): a simple linear curve that
- /// treats of capacity as an unusable reserve, and synthesizes an
- /// EW landmark at of reported capacity. Lets the runtime
- /// estimate improve on raw reported remaining until a real calibration replaces it.
+ /// Builds a conservative blind-guess baseline calibration (no run required), DIFFERENTIATED by LTO
+ /// generation. The runtime stops content when reported ≤ margin + reserve, so margin is a
+ /// capacity-fraction upper bound on the driver's tail over-report — guaranteeing actual remaining ≥ the
+ /// TOC reserve. The EW landmark is a real (deliberately under-estimated) runway on LTO-4+, and a tiny
+ /// emergency backstop on the older collapse-prone drives (where the physical EW fires uselessly late).
+ /// Lets the runtime estimate improve on raw reported remaining until a measured calibration replaces it.
///
- public static ITapeCalibration Apriori(
- string profileKey, long capacity, double marginPercent = 5.0, double remainingAtEwPercent = 7.0)
+ ///
+ /// The three behavioral envelopes below were derived from real AIT/DAT/DLT/LTO-3/4/6 calibration runs:
+ ///
+ ///
+ /// Generation 0 (pre-LTO forced-LTO: AIT/DAT/DLT/SDLT). Reported COLLAPSES near EOM and the
+ /// physical EW fires uselessly late (< ~1.5 MB … 0.5 GB before EOM); one member (DLT-V4) even
+ /// OVER-reports in the tail with a small phantom. So the reserve MUST come from the curve, well
+ /// before the collapse — a 2%-of-capacity margin covers the worst observed case. BOM error swings
+ /// both ways (AIT/DLT over-report ~1.5–2.2%, DAT is nearly truthful).
+ ///
+ ///
+ /// Generations 1–3 (LTO-1..3). Reported collapses to 0 at EW (LTO-3: an abrupt cliff at
+ /// ~0.9% of capacity); BOM error up to ~4% in EITHER direction (LTO-3 UNDER-reports 3.8%); the
+ /// physical EW fires ~0.1% before EOM — a backstop only. A 2% margin clears the cliff with ~2× safety.
+ ///
+ ///
+ /// Generations 4+ (LTO-4+). Smooth, reliable physical EW ~4% before EOM with a large
+ /// phantom-free runway; small BOM error (LTO-4 −0.76%, LTO-6 +0.19%). The physical-EW byte-count is
+ /// the primary mechanism, so we store a REAL runway (under-estimated to ~3% for safety) and only a
+ /// ~1% margin.
+ ///
+ ///
+ /// The stored EW landmark cooperates with the runtime's "tighten-only" rule
+ /// (estimate = min(curveEstimate, EwToEomDistance − bytesAfterPhysicalEw)): on LTO-4+ the real
+ /// runway sharpens the estimate once the hardware EW fires, while on the collapse drives the tiny
+ /// backstop can only ever STOP the caller, never inflate remaining — so a late/stale hardware EW can
+ /// never cause an overrun.
+ ///
+ /// The drive+media profile key this baseline is for.
+ /// Nominal content capacity in bytes.
+ /// The (possibly forced) LTO generation: 0 = pre-LTO SCSI-addressabele forced-LTO,
+ /// 1..3 = LTO-1..3, ≥ 4 = LTO-4+. Negative is treated as 0 (unknown/pre-LTO, most pessimistic).
+ public static ITapeCalibration Apriori(string profileKey, long capacity, int ltoGeneration = -1)
{
if (capacity < 0) capacity = 0;
- long margin = (long)(capacity * marginPercent / 100.0);
- long ewReported = (long)(capacity * remainingAtEwPercent / 100.0);
+
+ // Resolve the generation into a safety envelope:
+ // marginPct : conservative over-report/collapse envelope as a fraction of capacity (drives the stop).
+ // ewActual : a-priori EwToEomDistance — a real (under-estimated) runway on LTO-4+, ~0 backstop below.
+ // floor : absolute lower bound on the margin so tiny cartridges still get a sane buffer.
+ (double marginPct, long ewActual, long floor) = ltoGeneration switch
+ {
+ // LTO-4+ : ~1% envelope (LTO-4 −0.76%, LTO-6 +0.19% observed); 3% runway UNDER-estimates the ~4% real.
+ >= 4 => (0.010, (long)(capacity * 0.030), 64L * 1024 * 1024),
+ // LTO-1..3 : 2% envelope clears LTO-3's 0.9% cliff with ~2× safety; EW ≈ EOM ⇒ 1 MB backstop only.
+ >= 1 => (0.020, 1L * 1024 * 1024, 16L * 1024 * 1024),
+ // Generation 0 / unknown : 2% envelope covers DLT-V4's phantom + tail over-report; 1 MB backstop only.
+ _ => (0.020, 1L * 1024 * 1024, 8L * 1024 * 1024),
+ };
+
+ long margin = Math.Max(floor, (long)(capacity * marginPct));
long capacityActual = Math.Max(0L, capacity - margin);
+ ewActual = Math.Min(ewActual, capacityActual);
- // A-priori calibration curve: ReportedRemaining -> ActualRemaining
- // (blind linear model; example numbers for an ~780 GB LTO-4 at margin=5%, ewAt=7%)
- //
- // ActualRemaining
- // ^
- // 741┤ capacityActual ● BOM
- // (GB)│ = capacity - margin ╱ (reported=780, actual=741)
- // │ ╱
- // │ ╱
- // │ ╱ slope ≈ 1
- // │ ╱ (actual ≈ reported - margin)
- // │ ╱
- // │ ╱
- // │ ╱
- // 16┤ - - - - - - - - - - - - -◆ EW landmark (fake / synthesized)
- // │ ╱ : reported = ewReported (7%) = 54.6 GB
- // │ ╱ : actual = ewReported-margin = 15.6 GB
- // │ ╱ : → EwToEomDistance
- // │ ╱ :
- // 0┤───────●─────────────────┼───────────────────────────────────→ ReportedRemaining
- // 0 margin 54.6 780 (GB)
- // │ (39 GB) (ewReported) (capacity)
- // │ ↑
- // │ blind stop point: driver still reports `margin` free,
- // │ but real writable space is already 0 (curve clamps below here)
- //
- // Anchors stored in curve[]: (margin, 0) and (capacity, capacityActual)
- // EW point (nullable): (ewReported, ewReported - margin)
- // Model: ActualRemaining ≈ ReportedRemaining - margin, floored at 0
-
- // Curve (ascending by ReportedRemaining):
- // at reported == margin → actual == 0 (blind stop point)
- // at reported == capacity → actual == capacity − margin (BOM)
+ // Conservative linear curve: actual ≈ reported − margin, clamped. Because margin ≥ the worst tail
+ // over-report, TranslateReportedToActual never overestimates actual (see design doc §5.1).
var curve = new List
{
new(margin, 0L),
new(capacity, capacityActual),
};
- CalibrationPoint? ew = new CalibrationPoint(ewReported, Math.Max(0L, ewReported - margin));
+ // EW landmark: reported = ewActual + margin (matches the over-report), actual = ewActual.
+ CalibrationPoint? ew = new CalibrationPoint(ewActual + margin, ewActual);
- // A-priori assumes NO capacity boost at BOM (quantity (4) == the nominal capacity) and treats the
- // whole margin as phantom free space still claimed at hard EOM (quantity (5)).
- return new TapeCalibration("tapelibnet-cal-apriori/2", profileKey, capacity, margin, capacityActual, curve, ew);
+ return new TapeCalibration(
+ "tapelibnet-cal-apriori/2", profileKey, capacity, margin, capacityActual, curve, ew);
}
-
+
#endregion
#region *** Translation ***
diff --git a/TapeLibNET/TapeDrive.cs b/TapeLibNET/TapeDrive.cs
index 5a80cd7..32f123b 100644
--- a/TapeLibNET/TapeDrive.cs
+++ b/TapeLibNET/TapeDrive.cs
@@ -286,6 +286,13 @@ public long GetReportedContentRemaining()
public bool IsLto5PlusDrive => m_backend is TapeDriveWin32Backend wbe && wbe.IsLto5Plus
|| m_backend is RemoteTapeDriveBackend rbe && rbe.IsLto5Plus;
+ ///
+ /// Positive value if the drive is a true LTO drive; 0 if pre-LTO SCSI-adressable drive; -1 otherwise
+ ///
+ public int LtoGeneration => m_backend is TapeDriveWin32Backend wbe? wbe.LtoGeneration
+ : m_backend is RemoteTapeDriveBackend rbe? rbe.LtoGeneration
+ : -1;
+
///
/// Desired LOGICAL early-warning reserve, in bytes before physical EOM (0 = none). TapeDrive maps
/// the backend's physical EW/PEW and driver ReportedRemaining — through the active
@@ -604,22 +611,57 @@ private void SelectEarlyWarningMechanism()
bool backendHasEw = backendMech is EarlyWarningMechanism.ProgrammableEarlyWarning
or EarlyWarningMechanism.HardwareEarlyWarning;
+ // A pre-LTO / LTO-1..3 physical EW fires far too late to be the real mechanism — the a-priori curve is.
+ bool ewIsUseful = backendHasEw && LtoGeneration >= 4;
long capacity = Capacity;
if (capacity > 0L)
{
- // Synthesize an a-priori baseline so the reserve is honored even with no measured data.
- // A backend physical EW, when present, opportunistically sharpens the tail estimate —
- // hence the higher (Hardware/Programmable) precision label in that case.
- m_aprioriCalibration = TapeCalibration.Apriori(DriveProfileKey, capacity);
- m_ewMechanism = backendHasEw ? backendMech : EarlyWarningMechanism.Uncalibrated;
+ if (m_backend.ReportsExactRemaining)
+ {
+ // Honest backend (un-emulated virtual): identity calibration — no margin, so the reserve
+ // fires exactly at TOC size and "space remaining" is the truth. This is what lets the
+ // multivolume tests tune to exact capacities, immune to a-priori constant changes.
+ m_aprioriCalibration = TapeCalibration.Ideal(DriveProfileKey, capacity);
+ m_ewMechanism = EarlyWarningMechanism.Uncalibrated; // FIXME: Consider introducing EarlyWarningMechanism.Exact
+ }
+ else
+ {
+ // Synthesize an a-priori baseline so the reserve is honored even with no measured data.
+ // A backend physical EW, when present, opportunistically sharpens the tail estimate —
+ // hence the higher (Hardware/Programmable) precision label in that case.
+ m_aprioriCalibration = TapeCalibration.Apriori(DriveProfileKey, capacity, LtoGeneration);
+ m_ewMechanism = ewIsUseful ? backendMech : EarlyWarningMechanism.Uncalibrated;
+ }
}
else
{
// Capacity unknown: rely solely on the backend's physical EW if it has one.
- m_ewMechanism = backendHasEw ? backendMech : EarlyWarningMechanism.None;
+ m_ewMechanism = ewIsUseful ? backendMech : EarlyWarningMechanism.None;
}
}
+ ///
+ /// The pure estimate logic shared by and
+ /// , given an already-fetched
+ /// value — so callers can control (and throttle) the device poll themselves.
+ ///
+ /// Before physical EW → the calibrated ReportedRemaining → ActualRemaining curve.
+ /// After physical EW → the precise, self-anchored per-cartridge byte-count from the EW landmark
+ /// (EwToEomDistance − bytesSinceEw). We TRUST the byte-count in the tail rather than combine it
+ /// with the curve: the curve is unreliable there — on collapse drives (LTO-0..3) it has already
+ /// dropped to ~0 while real capacity remains, so a min() would wrongly abandon the writable tail; the
+ /// byte-count can never OVER-estimate (measured landmarks are exact; a-priori landmarks are set ≤ the
+ /// real runway), so it never risks an overrun. is IGNORED post-EW.
+ ///
+ ///
+ private long EstimateActualRemainingCore(ITapeCalibration cal, long reported)
+ {
+ if (m_physicalEwSeen)
+ return Math.Max(0L, cal.EwToEomDistance - BytesAfterPhysicalEw());
+
+ return cal.TranslateReportedToActual(reported);
+ }
+
///
/// Maps the backend's physical early warning + calibrated ReportedRemaining onto the caller's
/// logical reserve. With no reserve requested or no matching
@@ -641,11 +683,14 @@ private bool EvaluateLogicalEarlyWarning(int written, bool physicalEw)
return physicalEw; // capacity-unknown fallback: physical EW only
// Precise tail regime: after physical EW, byte-count down from the (measured or a-priori)
- // EW→EOM distance using the drive's authoritative block position. No Remaining query needed.
+ // EW→EOM distance. No Remaining query needed. Refresh the pacing hint so ClampWriteToEarlyWarning
+ // stays correct in the post-physical-EW / pre-logical-EW window (where a stale, large headroom
+ // would otherwise suppress clamping of a big final write near the reserve).
if (m_physicalEwSeen)
{
- long actualRemaining = Math.Max(0L, cal.EwToEomDistance - BytesAfterPhysicalEw());
- return actualRemaining <= m_desiredEarlyWarning;
+ long est = EstimateActualRemainingCore(cal, 0L /* reported ignored post-EW */);
+ m_writableHeadroomAtLastPoll = est - m_desiredEarlyWarning;
+ return est <= m_desiredEarlyWarning;
}
// Before physical EW: consult the curve on ReportedRemaining, throttling the costly query,
@@ -656,9 +701,9 @@ private bool EvaluateLogicalEarlyWarning(int written, bool physicalEw)
return physicalEw;
m_bytesSinceRemainingPoll = 0L;
- long est = cal.TranslateReportedToActual(GetReportedContentRemaining());
- m_writableHeadroomAtLastPoll = est - m_desiredEarlyWarning; // paces the next poll
- return est <= m_desiredEarlyWarning || physicalEw;
+ long est2 = EstimateActualRemainingCore(cal, GetReportedContentRemaining());
+ m_writableHeadroomAtLastPoll = est2 - m_desiredEarlyWarning; // paces the next poll
+ return est2 <= m_desiredEarlyWarning || physicalEw;
}
///
@@ -806,12 +851,12 @@ public long EstimateActualRemaining()
long reported = GetReportedContentRemaining();
if (reported < 0L)
return 0L;
+
ITapeCalibration? cal = EffectiveCalibration;
if (cal is null)
return reported;
- if (m_physicalEwSeen)
- return Math.Max(0L, cal.EwToEomDistance - BytesAfterPhysicalEw());
- return cal.TranslateReportedToActual(reported);
+
+ return EstimateActualRemainingCore(cal, reported);
}
#endregion // *** Calibration ***
diff --git a/TapeLibNET/TapeDriveBackend.cs b/TapeLibNET/TapeDriveBackend.cs
index 1f8c569..3feaa59 100644
--- a/TapeLibNET/TapeDriveBackend.cs
+++ b/TapeLibNET/TapeDriveBackend.cs
@@ -158,6 +158,13 @@ protected TapeDriveBackend(ILoggerFactory loggerFactory)
#region *** Early Warning ***
+ ///
+ /// True when this backend reports EXACT remaining capacity (no over/under-report to compensate for), so
+ /// should hold NO pessimistic a-priori margin. False for all real/emulated
+ /// hardware. Only an un-emulated virtual drive is honest by construction.
+ ///
+ public virtual bool ReportsExactRemaining => false;
+
///
/// If early warnings are being reported. This is what the drive actually does — which may differ
/// from what was requested via , exactly like block size.
@@ -165,7 +172,9 @@ protected TapeDriveBackend(ILoggerFactory loggerFactory)
///
public virtual bool ReportsEarlyWarning => false;
- /// How is currently realized (best available mechanism).
+ ///
+ /// How is currently realized (best available mechanism).
+ ///
public virtual EarlyWarningMechanism EarlyWarningMechanism => EarlyWarningMechanism.None;
///
diff --git a/TapeLibNET/TapeDriveWin32Backend.cs b/TapeLibNET/TapeDriveWin32Backend.cs
index 8657160..ca4590c 100644
--- a/TapeLibNET/TapeDriveWin32Backend.cs
+++ b/TapeLibNET/TapeDriveWin32Backend.cs
@@ -143,6 +143,7 @@ public partial class TapeDriveWin32Backend(ILoggerFactory loggerFactory) : TapeD
public bool IsLto => m_ltoGeneration >= 0; // FIXME: To experiment with SCSI support on pre-LTO drives, set to 0. Otherwise, to 1
public bool IsLto5Plus => m_ltoGeneration >= 5;
+ public int LtoGeneration => m_ltoGeneration;
#endregion
diff --git a/TapeLibNET/Virtual/VirtualTapeDriveBackend.EW.cs b/TapeLibNET/Virtual/VirtualTapeDriveBackend.EW.cs
index 4128f06..4b06d3e 100644
--- a/TapeLibNET/Virtual/VirtualTapeDriveBackend.EW.cs
+++ b/TapeLibNET/Virtual/VirtualTapeDriveBackend.EW.cs
@@ -49,6 +49,9 @@ internal void ApplyEwProfileToMedia()
#region *** Early Warning Overrides ***
+ ///
+ public override bool ReportsExactRemaining => m_ewProfileForNew is null;
+
///
public override EarlyWarningMechanism EarlyWarningMechanism
=> m_ewProfileForNew is { EarlyWarningZone: > 0 }
diff --git a/TapeServiceNET/TapeDriveGrpcService.cs b/TapeServiceNET/TapeDriveGrpcService.cs
index 83d4afb..258bc5f 100644
--- a/TapeServiceNET/TapeDriveGrpcService.cs
+++ b/TapeServiceNET/TapeDriveGrpcService.cs
@@ -49,6 +49,9 @@ public class TapeDriveGrpcService(TapeDriveSessionRegistry registry, ILogger
diff --git a/TapeServiceNET/TempVirtualTapeDriveBackend.cs b/TapeServiceNET/TempVirtualTapeDriveBackend.cs
index f26599d..27755c1 100644
--- a/TapeServiceNET/TempVirtualTapeDriveBackend.cs
+++ b/TapeServiceNET/TempVirtualTapeDriveBackend.cs
@@ -51,6 +51,7 @@ internal sealed class TempVirtualTapeDriveBackend(
public override bool HasInitiatorPartition => _inner.HasInitiatorPartition;
public override bool SupportsSetmarks => _inner.SupportsSetmarks;
public override bool SupportsSeqFilemarks => _inner.SupportsSeqFilemarks;
+ public override bool ReportsExactRemaining => _inner.ReportsExactRemaining;
public override bool Open(uint driveNumber) => _inner.Open(driveNumber);
public override void Close() => _inner.Close();
diff --git a/docs/Design-RemainingAndEw.md b/docs/Design-RemainingAndEw.md
index 4c0a437..0e644d2 100644
--- a/docs/Design-RemainingAndEw.md
+++ b/docs/Design-RemainingAndEw.md
@@ -220,7 +220,7 @@ compares a profile key); the concrete type is JSON-serialized inside TapeLibNET.
| `TranslateReportedToActual(reported)` | Pure curve-only translation with end clamping (the before-EW / no-EW branch). |
| `SaveTo(stream)` | Writes the opaque JSON blob the app persists verbatim. |
-Factories: `FromMeasurements(...)` (a run), `Apriori(capacity, marginPercent=5, remainingAtEwPercent=7)`
+Factories: `FromMeasurements(...)` (a run), `Apriori(profileKey, capacity, ltoGeneration)`
(a blind-guess baseline usable before any run, so estimates improve day one), `LoadFrom(stream)`. Key design
points:
@@ -913,6 +913,10 @@ This is the entire justification of the calibration feature, and it is stated in
generation-dependent and can be **negative** (LTO-3 −3.8%, LTO-6 +0.19%); the "inflated capacity at BOM"
axis should read "capacity mis-report at BOM (may be negative = under-report)".
+**Note on the after-physical-EW estimate**: It now flows
+ through `EstimateActualRemainingCore` and **trusts the byte-count** (not a min with the curve); s.
+ the rejected-alternative note in 7.2.1.
+
### 5.2 Emulation — two explicit anchors
```csharp
@@ -992,6 +996,8 @@ derived (`Uncalibrated`, `Calibrated`) and how EW trips (`HardwareEarlyWarning`,
`ProgrammableEarlyWarning`) — and drives `RemainingAndEwStatus` and the *Estimation by* row.
`TapeDrive.EarlyWarning` is the byte reserve, `IsEarlyWarning` the sticky "reserve was crossed" flag, and
`SetEarlyWarning(0)` additionally asks the backend to report its physical EW.
+Notice that on LTO generations 0–3 (incl. pre-LTO drives) the mechanism is reported as
+*Uncalibrated* (a-priori), not *Hardware*, because the late physical EW is pre-empted by the curve (s. 7.2.2).
### 5.5 UI — writable-first, with reported and estimated always paired
@@ -1266,101 +1272,138 @@ multi-hour run unattended.
---
-## Part 7 — Remaining tasks
-
-### 7.1 UI for Resume & Recalibrate — TapeWinNET (WPF) and TapeConNET (CLI)
+## Part 7 — Multi-generation calibration & the differentiated a-priori model
+
+### 7.1 The cross-drive calibration campaign [DONE]
+
+The estimator was validated against **eight real cartridges across six drives and three behavioral
+classes**, by forcing pre-LTO drives to be treated as "LTO generation 0" whenever SCSI INQUIRY
+(vendor/product/revision) succeeds — which held for every AIT, DAT-320 and DLT-V4 unit tested. Every run
+used the 40%-tail two-phase sampler.
+
+| Drive | Class (gen) | Reported @ BOM | Actual capacity | **BOM error (reported − actual)** | Phantom @ EOM | **EW → EOM (actual runway)** | Reported collapse near EOM? |
+|---|---|---|---|---|---|---|---|
+| AIT-2 40 GB (SONY SDX-560V) | 0 | 42.60 GB | 41.87 GB | **+0.72 GB (+1.73%)** over | 0 | **1.5 MB (0.004%)** | yes |
+| AIT-2 79 GB (SONY SDX-560V) | 0 | 85.04 GB | 83.21 GB | **+1.83 GB (+2.20%)** over | 0 | **0.38 MB (0.0005%)** | yes |
+| DAT-320 76 GB (HP DAT320) | 0 | 82.03 GB | 81.95 GB | +0.08 GB (+0.10%) over | 0 | 240 MB (0.29%) | yes |
+| DAT-320 150 GB (HP DAT320) | 0 | 166.21 GB | 165.95 GB | +0.26 GB (+0.16%) over | 0 | 499 MB (0.30%) | yes |
+| DLT-V4 160 GB (QUANTUM) | 0 | 167.70 GB | 165.24 GB | **+2.46 GB (+1.49%)** over | **12.5 MB** | 1.6 MB (0.001%) | **no** |
+| LTO-3 (QUANTUM ULTRIUM 3) | 1–3 | 410.15 GB | 426.49 GB | **−16.34 GB (−3.83%)** UNDER | 0 | 437 MB (0.10%) | yes (abrupt cliff) |
+| LTO-4 (QUANTUM ULTRIUM 4) | 4+ | 839.10 GB | 845.49 GB | −6.40 GB (−0.76%) under | 383 MB | **31.8 GB (3.76%)** | no |
+| LTO-6 (HP Ultrium 6) | 4+ | 2543.4 GB | 2538.6 GB | **+4.70 GB (+0.19%)** over | 2.39 GB | **110 GB (4.34%)** | no |
+
+Five findings drive the model:
+
+1. **The physical EW landmark is UNUSABLE for capacity on LTO-0..3.** It fires 0.38 MB … 499 MB before
+ EOM — frequently *below* the TOC reserve it is supposed to protect. The EW-anchored byte-count that is
+ the whole game on LTO-4+ cannot anchor the reserve here; the **reported-remaining curve must, stopping
+ before the collapse**.
+
+2. **BOM error swings both ways and by generation.** LTO-3 UNDER-reports 3.8%; AIT/DLT OVER-report
+ 1.5–2.2%; DAT is nearly truthful; LTO-6 over-reports 0.19%. The old single-shape a-priori (a benign
+ linear margin) is simply wrong. **No fixed sign or magnitude may be assumed.**
+
+3. **The tail collapse is generally SAFE, with one dangerous exception.** On AIT/DAT/LTO-3 the reported
+ figure collapses toward 0 in the tail — it UNDER-states actual near EOM (conservative). **DLT-V4 is the
+ trap:** it does *not* collapse, carries a 12.5 MB phantom, and OVER-reports in the tail (claims 37 MB
+ when 1.5 MB truly remains). A naïve "stop when reported ≤ TOC reserve" would stop DLT-V4 with ~1 MB left.
+
+4. **Phantom is 0 everywhere except DLT-V4 (12.5 MB).** For pre-LTO the phantom concept is a red herring
+ apart from that one unit.
+
+5. **LTO-3's collapse is a CLIFF, not a slide:** reported jumps 3.68 GB → 0 across the last ~50 MB, so the
+ usable reported floor is ~0.9% of capacity — content must stop while reported is still well above it.
+
+### 7.2 The generation-differentiated a-priori model [DONE]
+
+`TapeCalibration.Apriori(profileKey, capacity, ltoGeneration)` replaces the old
+`Apriori(profileKey, capacity, marginPercent, remainingAtEwPercent)`. It resolves the (possibly forced)
+generation into one of three **safety envelopes**, chosen to dominate every observed error with a wide
+margin — err pessimistic, always:
+
+| Generation | `marginPct` (capacity fraction) | Margin floor | `EwToEomDistance` (a-priori) | Rationale |
+|---|---|---|---|---|
+| **≥ 4** (LTO-4+) | 1.0% | 64 MB | **3.0% of capacity** (a real, under-estimated runway) | small BOM error; reliable ~4% physical-EW runway |
+| **1–3** (LTO-1..3) | 2.0% | 16 MB | **1 MB** (emergency backstop) | ±4% BOM error; abrupt 0.9% collapse cliff; EW ≈ EOM |
+| **0 / unknown** (pre-LTO forced-LTO) | 2.0% | 8 MB | **1 MB** (emergency backstop) | heterogeneous; covers DLT-V4 phantom + tail over-report; EW ≈ EOM |
+
+The runtime stops content when `reported ≤ margin + reserve`, so `margin` is a **capacity-fraction upper
+bound on the driver's tail over-report** — guaranteeing actual remaining ≥ the TOC reserve. `margin` is a
+fraction (the over-report envelope scales with tape length) while the TOC **reserve** stays the fixed
+`DefaultTOCCapacity` floor (1 GB / 512 MB / 32 MB); the two combine at the stop point.
+
+The `int ltoGeneration` parameter (surfaced by `TapeDriveWin32Backend.LtoGeneration`, `-1`/`0` for
+non-LTO/unknown → the most pessimistic envelope) keeps the door open to per-generation refinement later via
+a simple relational `switch`. The three findings above live verbatim in the method's ``.
+
+**Honest cost.** On collapse drives the a-priori deliberately wastes 1–2% of tape (e.g. ~8 GB on LTO-3,
+~2.85 GB on DAT-320) by stopping before the cliff. That is the correct trade — a wasted 2% beats an overrun
+that destroys the backup — and measured/shipped profiles reclaim most of it (7.3).
+
+### 7.2.1 The unified estimate core & the "trust the byte-count" tail rule [DONE]
+
+Both the reporting path (`EstimateActualRemaining`) and the decision path
+(`EvaluateLogicalEarlyWarning`) now share one private `EstimateActualRemainingCore(cal, reported)`, so they
+can never disagree:
+
+- **Before physical EW** → the calibrated `ReportedRemaining → ActualRemaining` curve.
+- **After physical EW** → the precise, self-anchored per-cartridge byte-count `EwToEomDistance − bytesSinceEw`;
+ `reported` is IGNORED.
+
+> **Design note — rejected alternative.** An earlier proposal took `min(curveEstimate, byteCount)` after
+> EW ("tighten only"). This is WRONG for **measured collapse profiles**: on LTO-3 the curve retains the
+> `reported == 0` tail so `TranslateReportedToActual(0) = 0`, and physical EW fires *at* the collapse, so
+> `min(0, 437 MB) = 0` — abandoning the 437 MB of writable tail the byte-count exists to protect. And it
+> helps nowhere: on LTO-4+ the byte-count is already the smaller figure (min ≡ replace), and on a-priori
+> collapse drives the curve trips logical EW long before physical EW (sticky). So the tail rule is simply
+> **trust the byte-count**, which can never over-estimate: measured landmarks are exact, and a-priori
+> landmarks are set ≤ the real runway.
+
+One correctness fix accompanied the refactor: the **post-physical-EW / pre-logical-EW window** (the ~31 GB
+stretch on LTO-4+ with a small reserve). There `IsEarlyWarning` is still false so `ClampWriteToEarlyWarning`
+is active, but `m_writableHeadroomAtLastPoll` was stale (huge) from the last pre-EW poll, suppressing the
+clamp of a large final write near the reserve. `EvaluateLogicalEarlyWarning` now refreshes the headroom in
+its post-EW branch (free — the byte-count needs no device poll).
+
+### 7.2.2 Honest mechanism labeling [DONE]
+
+On generations 0–3 the backend *does* advertise a hardware EW, but it fires far too late to be the real
+mechanism — the a-priori curve trips logical EW well before it (e.g. DAT-320: content stops ~3.3 GB early,
+versus the drive's own ~0.5 GB EW, which is never reached). `SelectEarlyWarningMechanism` therefore labels
+the mechanism honestly:
-Surface the new `CalibrationMode` in both apps, matching the service extension.
-
-- **WPF (`CalibrateWindow`):** replace the implicit New-only flow with a mode selector — a radio group:
- ```
- Calibration mode:
- (•) New (default)
- ( ) Resume previous run [requires cartridge with a resumable run that matches this drive]
- ( ) Recalibrate (tail check) [requires cartridge with a saved calibration run that matches this drive]
- ```
- Offer a button ("Inspect media") to quickly validate the two media-dependent options by probing the cartridge header via a lightweight service call
- and inspecting the `CalibrationStore` for a matching profile; show a one-line result ("Resumable run found: 41%
- written, HP Ultrium 6, firmware 35GD→35GE"). Wire the selection to `CalibrateRequest.Mode`; on a
- `FullRecalibrationAdvised` verdict, route the service's `Confirm` to a WPF dialog; render
- `RecalibrationDelta`/`RecalibrationVerdict` in `CalibrationWindow` (before/after rows + verdict banner).
-
- **[DONE — WPF half]** As-built, this landed as follows:
- - **`TapeCalibrator.InspectMedia()`** (read-only, `TapeCalibrator.cs`) and the service-level
- **`TapeServiceBase.ExecuteInspectCalibrationMediaAsync()`** (`TapeServiceBase.Calibrate.cs`) pair
- back the "Inspect media" button. The service method combines the on-tape header/checkpoint
- (calibrator) with a `CalibrationStore.Exists(ProfileKey)` lookup to recommend New/Resume/Recalibrate,
- returned as `InspectCalibrationMediaResult` (`ServiceOperationResult.cs`).
- - **Inspection is an optional convenience, never a gate** — all three modes stay enabled at all times;
- a wrong cartridge is already reported by the service with mode-appropriate text. The Inspect Media
- area is collapsed while "New run" is selected (`CalibrationRunViewModel.IsInspectAvailable`).
- - **`Confirm` was already WPF-routed** before this work — `WpfServiceHost.Confirm`
- (`TapeWinNET/Services/WpfServiceHost.cs`) marshals to a `SimpleBox` YesNo on the dispatcher, so no
- additional wiring was needed for the breach-confirm chain.
- - **VM split three ways:** `CalibrationRunViewModel` (mode radios, Inspect Media, the destructive run
- itself — renamed from the old overloaded `CalibrationViewModel`), `CalibrationResultViewModel`
- (Save/Apply, verdict banner, recalibration delta, the user-driven "Run Full Calibration..." follow-up),
- and `CalibrationResultViewModelBase` (shared display surface, also the base of
- `CalibrationProfilesViewModel` so the profiles browser reuses the same figures/verdict members
- without duplicating them).
- - **`CalibrationResultView`** (`TapeWinNET/Controls/`) is the extracted shared result `UserControl` —
- verdict banner, measured-result figures, before/after recalibration delta, and the reported→actual
- curve — dropped into both `CalibrationWindow.xaml` and `CalibrationProfilesWindow.xaml`, which just
- inherit its DataContext.
- - **"Run Full Calibration..."** does not launch a run itself; it closes the result window with
- `CalibrationResultViewModel.FullCalibrationRequested`, and `MainViewModel.Calibration.cs` re-opens
- `CalibrateWindow` (New preselected) — keeping run orchestration in one place, in addition to (not
- instead of) the service's own mid-operation `Confirm`-chain.
-- **CLI (`TapeConNET`):** add `--calibrate-resume` and `--calibrate-recheck` (or `--calibrate
- --mode=resume|recalibrate`); map `ITapeServiceHost.Confirm` to a Y/N prompt (or `--yes` for
- non-interactive); print the recalibration assessment table and verdict. **[Not yet done — CLI half remains.]**
-
-
-### 7.2 Update a-priori and "LTO-4-like" profiles from the real-hardware data
-
-The `Apriori` factory (`marginPercent 5`, `remainingAtEwPercent 7`) and `Lto4Like` defaults predate the real
-measurements and are now known to be off:
-
-- **Runway (`EwToEomDistance`)** is ~4% of capacity on LTO-4/6, not 7%; on LTO-3 it is ~0.1%.
-- **Phantom** is < 0.1% on real drives, not the 4–5% assumed.
-- **BOM error is small and generation-dependent, and can be NEGATIVE** (LTO-3 −3.8%, LTO-6 +0.19%). The
- "boost ≥ 0" assumption in `ReportedRemainingAnchors`/`Apriori` should be relaxed to allow a negative
- boost (under-report), and virtual emulation should be able to reproduce it.
-- **Preferred direction:** rather than hand-tuning synthetic constants, **ship measured per-generation
- reference calibrations** (LTO-3/4/6 now in hand) as embedded resources, loaded through the same
- `TapeCalibration.LoadFrom` path; a fresh run overrides. Retune the synthetic `Apriori`/`Lto4Like` only as
- a last-resort fallback for unmeasured generations.
-
-### 7.3 Rework how an a-priori profile is assigned when no calibration exists
-
-Today `SelectEarlyWarningMechanism` synthesizes an `Apriori` from nominal capacity whenever no measured
-profile matches. With real data available, revisit the whole a-priori story:
-
-- Prefer a **shipped per-generation reference profile** (7.2) matched by vendor/product/generation over the
- blind linear `Apriori`, so an un-calibrated-but-known drive still gets realistic EW behavior.
-- Fall back to the synthetic `Apriori` only for genuinely unknown drives, with corrected defaults (7.2).
-- Decide the matching granularity for reference profiles (generation-level, ignoring firmware and exact
- capacity bucket) versus the exact-key matching used for measured calibrations — likely a looser
- `IgnoreFirmware`/generation match for reference profiles, exact for measured ones.
-
-### 7.4 Evaluate pre-LTO drives for EW support — "LTO generation 0" (future)
-
-Investigate whether older linear/helical drives that TapeNET already supports — **AIT, DAT-320, SDLT /
-DLT-V4** — expose an early-warning mechanism and tolerate SCSI pass-through control/direct commands the same
-way LTO does. If any do, the whole EW / `EstimateActualRemaining` machinery could be extended to them,
-a real value-add for those users. Scope:
-
-- **Probe for EW capability** per drive family: does a `WRITE(6)` over SPTD surface an EOM-bit/early-warning
- sense before hard EOM? Do `LOG SENSE`/`READ POSITION` behave? Some of these are helical-scan (AIT/DAT) and
- may not have an LTO-style EW zone at all.
-- **If EW works:** treat the family as **"LTO generation 0"** — reuse `ScsiWriteDirect` sensing, the
- physical/logical EW mapping, and calibration unchanged, keyed by its own vendor/product/generation. This
- needs a small generalization of the LTO-gated code paths (currently `IsLto`-gated) to an "EW-capable via
- SPTD" predicate.
-- **If EW does not work** (likely for pure helical-scan or drives that reject SPTD): still provide a
- **meaningful a-priori profile** so the estimate improves over the raw driver figure — measured margins for
- these families if we can calibrate them, or conservative synthetic defaults otherwise.
-- **Deliverable either way:** an a-priori/reference profile per supported pre-LTO family, plus a documented
- determination of which families can and cannot participate in EW/estimation.
+```csharp
+// A pre-LTO / LTO-1..3 physical EW fires too late to be the real mechanism — the a-priori curve is.
+bool ewIsUseful = backendHasEw && LtoGeneration >= 4;
+m_ewMechanism = ewIsUseful ? backendMech : EarlyWarningMechanism.Uncalibrated;
+```
+This is a labeling change only (the estimate is unaffected): the UI's *"Estimation by"* now reads
+*Uncalibrated* rather than overstating *Hardware* on a drive whose hardware EW we provably pre-empt.
+
+### 7.3 Ship measured reference profiles per generation [PLANNED]
+
+The campaign produced **eight real curves** (AIT-2 ×2, DAT-320 ×2, DLT-V4, LTO-3/4/6). These should ship as
+embedded per-generation **reference calibrations**, loaded through the normal `TapeCalibration.LoadFrom`
+path and matched by **vendor/product/generation** (looser than the exact `vendor|product|revision|bucket`
+key used for user-measured profiles); a fresh user calibration still overrides. This is what turns "DAT-320
+wastes ~1.7%" into "DAT-320 is precise" and "we tested one AIT once" into shipped data — WITHOUT
+over-fitting the blind a-priori envelope (which must stay pessimistic to keep AIT-like sub-reserve EW drives
+safe). Remaining work: choose the embedding format, the matcher granularity, and the a-priori↔reference
+precedence, then wire the loader into `AutoLoadCalibrations`.
+
+### 7.4 Evaluate more pre-LTO families & sharpen the classes [FUTURE]
+
+The forced-"LTO generation 0" path is validated for AIT / DAT-320 / DLT-V4, all of which accept SCSI
+INQUIRY and SPTD direct writes and surface a usable (if late) EOM/EW sense. Remaining exploration:
+
+- **Widen the family sweep** — SDLT and other DLT variants, further AIT/DAT generations, LTO-1/2 — to
+ confirm the gen-0 / gen-1..3 envelopes hold or to justify a finer split (the `int` generation parameter
+ already supports adding envelopes without touching call sites).
+- **Per-family reference profiles** for every family that calibrates cleanly, so DLT-V4's phantom and
+ DAT-320's honest EW are exploited precisely rather than buried under the pessimistic blind envelope.
+- **Generalize the `IsLto`-gated code paths** to an "EW-capable via SPTD" predicate, so a newly qualified
+ pre-LTO family joins the estimation/EW machinery by data, not by code change.
+- **Determination table** — a documented list of which families can and cannot participate in
+ EW/estimation, with the measured margins (or conservative synthetic defaults) for each.
---
From 2c2b1240a55a7017dfbece0657802f737e10ea5b Mon Sep 17 00:00:00 2001
From: Alex K
Date: Mon, 24 Aug 2026 04:55:43 +0200
Subject: [PATCH 32/37] Add comment on DLT-V4 behavior.
---
TapeLibNET/TapeCalibrationOptions.cs | 3 ++-
TapeLibNET/TapeNavigator.cs | 2 +-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/TapeLibNET/TapeCalibrationOptions.cs b/TapeLibNET/TapeCalibrationOptions.cs
index 25ee520..a7835f8 100644
--- a/TapeLibNET/TapeCalibrationOptions.cs
+++ b/TapeLibNET/TapeCalibrationOptions.cs
@@ -38,6 +38,7 @@ public readonly record struct TapeCalibrationOptions
/// . LTO-3/4/6 runs proved it EQUALS the driver value
/// (LTO-4/6) or collapses identically (LTO-3), so it carries no independent signal — hence default
/// . Flip on only to re-verify on a new drive/generation.
+ /// Not supported on DLT-V4.
///
public bool CaptureLtoRemaining { get; init; }
@@ -69,7 +70,7 @@ public TapeCalibrationOptions()
BlocksPerChunk = DefaultBlocksPerChunk;
TailSampleFraction = DefaultTailSampleFraction; // reserve 40% of the budget for the EW→EOM tail
TailCapacityFraction = DefaultTailCapacityFraction; // tail = last 5% of capacity (or EW, whichever first)
- CaptureLtoRemaining = false; // proven redundant across LTO-3/4/6 — off by default
+ CaptureLtoRemaining = false; // proven redundant across LTO-3/4/6; not supported on DLVT-V4 — off by default
NumCheckpoints = DefaultNumCheckpoints; // 128 resumable body checkpoints (~1% granularity)
}
diff --git a/TapeLibNET/TapeNavigator.cs b/TapeLibNET/TapeNavigator.cs
index ac8912e..350cb97 100644
--- a/TapeLibNET/TapeNavigator.cs
+++ b/TapeLibNET/TapeNavigator.cs
@@ -867,7 +867,7 @@ public override bool MoveToBeginOfTOC()
if (WentOK)
SeekForwardPastTOCMark();
*/
- }
+ }
else // we're somewhere in the content
{
SeekForwardPastTOCMark();
From 29c4f54c26d08f96d42267eaea8a081e44630536 Mon Sep 17 00:00:00 2001
From: Alex K
Date: Tue, 25 Aug 2026 07:41:36 +0200
Subject: [PATCH 33/37] Update TOC emergency export suggestion comment in the
TapeLibNET service layer.
---
TapeLibNET/Services/TapeServiceBase.Backup.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/TapeLibNET/Services/TapeServiceBase.Backup.cs b/TapeLibNET/Services/TapeServiceBase.Backup.cs
index f283b2f..7949b3f 100644
--- a/TapeLibNET/Services/TapeServiceBase.Backup.cs
+++ b/TapeLibNET/Services/TapeServiceBase.Backup.cs
@@ -387,7 +387,7 @@ private BackupResult ExecuteBackupCore(BackupRequest request)
if (!emergencySaved)
{
throw new InvalidOperationException(
- "TOC backup failed — media TOC is lost. " +
+ "TOC backup failed. It is strongly advised to immediately export TOC to file (Media | Export TOC to file). " +
"The backed-up files are on the media but cannot be accessed without a TOC.");
}
}
From abf867744343e5255c6fa23d3097bde7276db1af Mon Sep 17 00:00:00 2001
From: Alex K
Date: Thu, 27 Aug 2026 14:51:30 +0200
Subject: [PATCH 34/37] Rename "LTO-4-like" emulated profile to
"EmulatedOverreport"
---
.../CalibrationAndLogicalEwTests.cs | 20 +++++------
TapeLibNET.Tests/CalibrationResumeTests.cs | 36 +++++++++----------
.../Services/ServiceCalibrationResumeTests.cs | 2 +-
.../Services/ServiceCalibrationTests.cs | 4 +--
.../VirtualDriveEarlyWarningTests.cs | 4 +--
TapeLibNET/Virtual/VirtualTapeEwProfile.cs | 4 +--
.../ViewModels/OpenVirtualDriveViewModel.cs | 10 +++---
.../VirtualDriveConfigViewModelBase.cs | 2 +-
8 files changed, 41 insertions(+), 41 deletions(-)
diff --git a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
index a0b8026..e58dc79 100644
--- a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
+++ b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
@@ -50,7 +50,7 @@ private static byte[] IncompressibleBlock(int size, int seed)
[Fact]
public void CalibrationRun_ProducesUsableMonotonicCurve_WithEwLandmark()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// Faster run: fewer samples, small interval so a 64 MB cartridge still yields several points.
var calibrator = new TapeCalibrator(drive)
@@ -90,7 +90,7 @@ public void CalibrationRun_ProducesUsableMonotonicCurve_WithEwLandmark()
[Fact]
public void CalibrationRun_RestoresPriorReserveAndCalibrations()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// Pre-existing reserve + a matching loaded calibration that the run must NOT taint or discard.
var preloaded = TapeCalibration.Apriori(drive.DriveProfileKey, Capacity);
@@ -122,7 +122,7 @@ public void CalibrationRun_RestoresPriorReserveAndCalibrations()
public void CalibrationRun_WithOverreport_CapturesBothBomAndEomAnchors(
double phantomFreePercent, double reportedBoostPercent)
{
- var profile = VirtualTapeEwProfile.Lto4Like(
+ var profile = VirtualTapeEwProfile.EmulatedOverreport(
Capacity, ewZonePercent: 4.0,
phantomFreePercent: phantomFreePercent,
reportedBoostPercent: reportedBoostPercent);
@@ -169,7 +169,7 @@ public void CalibrationRun_WithOverreport_CapturesBothBomAndEomAnchors(
[Fact]
public void CalibrationJson_RoundTrips_AndRejectsUnknownFormat()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
var calibrator = new TapeCalibrator(drive)
{
Options = new TapeCalibrationOptions
@@ -233,7 +233,7 @@ public void Apriori_ProducesConservativeUsableCurve_WithoutRun()
[Fact]
public void MultiProfile_SelectsMatchingKey_AndTracksLoadUnload()
{
- var (drive, backend) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, backend) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
string matchingKey = drive.DriveProfileKey;
var matching = TapeCalibration.Apriori(matchingKey, Capacity);
@@ -273,7 +273,7 @@ public void LogicalEw_BeforePhysicalEw_FiresFromCurveWithLargeReserve()
// Capacity must exceed the internal ReportedRemaining poll interval (64 MB) so the throttled
// before-EW curve poll fires at least once before the physical EW zone near the tail.
const long largeCapacity = 256L * 1024 * 1024;
- var profile = VirtualTapeEwProfile.Lto4Like(largeCapacity);
+ var profile = VirtualTapeEwProfile.EmulatedOverreport(largeCapacity);
var (drive, _) = CreateDrive(profile, capacity: largeCapacity);
// A LARGE reserve so the calibrated curve trips logical EW well before the physical EW zone.
@@ -315,7 +315,7 @@ public void LogicalEw_BeforePhysicalEw_FiresFromCurveWithLargeReserve()
[Fact]
public void LogicalEw_AfterPhysicalEw_FiresFromByteCountWithSmallReserve()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// A SMALL reserve so logical EW only trips in the precise after-physical-EW byte-count regime.
long reserve = 256L * 1024;
@@ -364,7 +364,7 @@ public void LogicalEw_AfterPhysicalEw_FiresFromByteCountWithSmallReserve()
public void EstimateActualRemaining_TracksTrueRemaining_AcrossRegimes()
{
// Calibrate first so the drive has a measured curve to translate with.
- var (calDrive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (calDrive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
ITapeCalibration? cal = new TapeCalibrator(calDrive)
{
Options = new TapeCalibrationOptions
@@ -377,7 +377,7 @@ public void EstimateActualRemaining_TracksTrueRemaining_AcrossRegimes()
Assert.NotNull(cal);
// Fresh cartridge, load the measured calibration, then write and compare estimate vs ground truth.
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
Assert.True(drive.AddCalibration(cal!));
Assert.True(drive.SetEarlyWarning(1L * 1024 * 1024));
@@ -418,7 +418,7 @@ public void EstimateActualRemaining_TracksTrueRemaining_AcrossRegimes()
[Fact]
public void EarlyWarningRuntime_ResetsOnMediaReload()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
Assert.True(drive.SetEarlyWarning(256L * 1024));
int block = (int)drive.MaximumBlockSize;
diff --git a/TapeLibNET.Tests/CalibrationResumeTests.cs b/TapeLibNET.Tests/CalibrationResumeTests.cs
index a8572d7..932b243 100644
--- a/TapeLibNET.Tests/CalibrationResumeTests.cs
+++ b/TapeLibNET.Tests/CalibrationResumeTests.cs
@@ -212,7 +212,7 @@ public void Unpack_OfForeignBlock_ReturnsNull()
[Fact]
public void Resume_ContinuesAbortedRun_ToCompletion()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// Simulate an interruption ~halfway — several body checkpoints are on tape by then.
var run = new TapeCalibrator(drive) { Options = FastOptions() };
@@ -238,12 +238,12 @@ public void Resume_ContinuesAbortedRun_ToCompletion()
public void Resume_ProducesEquivalentCalibration_ToAnUninterruptedRun()
{
// Baseline: a clean, uninterrupted run.
- var (driveA, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (driveA, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
ITapeCalibration? clean = new TapeCalibrator(driveA) { Options = FastOptions() }.Run();
Assert.NotNull(clean);
// Interrupted-then-resumed run on an equivalent cartridge.
- var (driveB, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (driveB, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
var run = new TapeCalibrator(driveB) { Options = FastOptions() };
Assert.Null(run.Run(new AbortAfterBytes(run, Capacity / 2)));
ITapeCalibration? resumed = new TapeCalibrator(driveB) { Options = FastOptions() }.Resume();
@@ -260,7 +260,7 @@ public void Resume_ProducesEquivalentCalibration_ToAnUninterruptedRun()
[Fact]
public void Resume_IsItselfResumable_ConvergesAfterRepeatedFailures()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// 1) Fresh run, interrupted early (~35%).
var r0 = new TapeCalibrator(drive) { Options = FastOptions() };
@@ -284,7 +284,7 @@ public void Resume_IsItselfResumable_ConvergesAfterRepeatedFailures()
[Fact]
public void Resume_OnBlankCartridge_ReturnsNull()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// No run has been performed: there is no header on the medium, so nothing to resume.
ITapeCalibration? resumed = new TapeCalibrator(drive) { Options = FastOptions() }.Resume();
@@ -294,7 +294,7 @@ public void Resume_OnBlankCartridge_ReturnsNull()
[Fact]
public void Resume_RestoresPriorReserveAndCalibrations()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// Interrupt a fresh run first so there is something to resume.
var run = new TapeCalibrator(drive) { Options = FastOptions() };
@@ -315,7 +315,7 @@ public void Resume_RestoresPriorReserveAndCalibrations()
[Fact]
public void Resume_RecoversFromTornLastCheckpoint()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// Abort mid-body so SEVERAL body checkpoints are on tape (16 checkpoints, aborted at ~50% ⇒ ~8).
var run = new TapeCalibrator(drive) { Options = FastOptions() };
@@ -337,7 +337,7 @@ public void Resume_RecoversFromTornLastCheckpoint()
[Fact]
public void Resume_OnForeignCartridgeWithRegularData_ReturnsNull()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// The user's mix-up: a cartridge carrying ordinary filemark-delimited data (backup-like sets) but
// NO calibration header at BOM. Resume must reject it cleanly — caught by the header-at-BOM check
@@ -367,7 +367,7 @@ public void Resume_OnForeignCartridgeWithRegularData_ReturnsNull()
[Fact]
public void Recalibrate_AfterCompleteRun_ReassessesTail_WithSmallDelta()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// A full run to completion leaves the resumable trail (header + body checkpoints) on tape.
var run = new TapeCalibrator(drive) { Options = FastOptions() };
@@ -408,7 +408,7 @@ public void Recalibrate_AfterCompleteRun_ReassessesTail_WithSmallDelta()
[Fact]
public void Recalibrate_OnBlankCartridge_ReturnsNullReassessed()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// No trail on the medium ⇒ nothing to re-measure from.
var existing = TapeCalibration.Apriori(drive.DriveProfileKey, Capacity);
@@ -422,7 +422,7 @@ public void Recalibrate_OnBlankCartridge_ReturnsNullReassessed()
public void Recalibrate_AfterDriveBehaviorChange_ReportsLargeEwShift()
{
// Original drive behavior: a wide 8% early-warning zone.
- var (drive, backend) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity, ewZonePercent: 8.0));
+ var (drive, backend) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity, ewZonePercent: 8.0));
ITapeCalibration? original = new TapeCalibrator(drive) { Options = FastOptions() }.Run();
Assert.NotNull(original);
@@ -432,7 +432,7 @@ public void Recalibrate_AfterDriveBehaviorChange_ReportsLargeEwShift()
// the profile (it does not wipe content), so the resumable trail survives and the tail
// re-measurement now sees the new, later early warning. Shrinking (not growing) the zone keeps
// the new EW point AHEAD of the resume position, so it is measured cleanly rather than truncated.
- backend.EmulatedEarlyWarning = VirtualTapeEwProfile.Lto4Like(Capacity, ewZonePercent: 2.0);
+ backend.EmulatedEarlyWarning = VirtualTapeEwProfile.EmulatedOverreport(Capacity, ewZonePercent: 2.0);
(ITapeCalibration? reassessed, TapeRecalibrationDelta delta) =
new TapeCalibrator(drive) { Options = FastOptions() }.Recalibrate(original!);
@@ -455,7 +455,7 @@ public void Recalibrate_AfterDriveBehaviorChange_ReportsLargeEwShift()
[Fact]
public void InspectMedia_AfterCompleteRun_ReportsResumableAndComplete()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
ITapeCalibration? cal = new TapeCalibrator(drive) { Options = FastOptions() }.Run();
Assert.NotNull(cal);
@@ -476,7 +476,7 @@ public void InspectMedia_AfterCompleteRun_ReportsResumableAndComplete()
[Fact]
public void InspectMedia_AfterAbortedRun_ReportsResumableButNotComplete()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
var run = new TapeCalibrator(drive) { Options = FastOptions() };
Assert.Null(run.Run(new AbortAfterBytes(run, Capacity / 2))); // interrupted ~halfway
@@ -493,7 +493,7 @@ public void InspectMedia_AfterAbortedRun_ReportsResumableButNotComplete()
[Fact]
public void InspectMedia_OnBlankCartridge_ReturnsNull()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// No run performed ⇒ no header at BOM ⇒ nothing to inspect.
Assert.Null(new TapeCalibrator(drive) { Options = FastOptions() }.InspectMedia());
@@ -502,7 +502,7 @@ public void InspectMedia_OnBlankCartridge_ReturnsNull()
[Fact]
public void InspectMedia_OnForeignCartridgeWithRegularData_ReturnsNull()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// Ordinary filemark-delimited data, but NO calibration header at BOM — a mixed-up cartridge.
Assert.True(drive.MoveToPartition(MediaPartition.Content));
@@ -524,7 +524,7 @@ public void InspectMedia_OnForeignCartridgeWithRegularData_ReturnsNull()
[Fact]
public void InspectMedia_IsNonDestructive_ResumeStillSucceeds()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
var run = new TapeCalibrator(drive) { Options = FastOptions() };
Assert.Null(run.Run(new AbortAfterBytes(run, Capacity / 2)));
@@ -554,7 +554,7 @@ public void InspectMedia_IsNonDestructive_ResumeStillSucceeds()
[Fact]
public void InspectMedia_DoesNotDisturbLoadedCalibrationsOrReserve()
{
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// Leave a resumable trail so InspectMedia has a header to read.
Assert.NotNull(new TapeCalibrator(drive) { Options = FastOptions() }.Run());
diff --git a/TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs b/TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs
index 9cf1d57..bfb3511 100644
--- a/TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs
+++ b/TapeLibNET.Tests/Services/ServiceCalibrationResumeTests.cs
@@ -38,7 +38,7 @@ public class ServiceCalibrationResumeTests : ServiceTestBase
VirtualTapeDriveCapabilities.WithFilemarksOnlyLargeBlocks,
vmd,
ioRate: ioRate,
- ewProfile: ewProfile ?? VirtualTapeEwProfile.Lto4Like(capacity)),
+ ewProfile: ewProfile ?? VirtualTapeEwProfile.EmulatedOverreport(capacity)),
$"OpenVirtualDriveAsync failed: {service.LastError}");
Assert.True(await service.LoadMediaAsync(),
diff --git a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
index efced57..8d3d9b6 100644
--- a/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
+++ b/TapeLibNET.Tests/Services/ServiceCalibrationTests.cs
@@ -22,7 +22,7 @@ public class ServiceCalibrationTests : ServiceTestBase
VirtualTapeDriveCapabilities.WithFilemarksOnlyLargeBlocks,
vmd,
ioRate: ioRate,
- ewProfile: ewProfile ?? VirtualTapeEwProfile.Lto4Like(capacity)),
+ ewProfile: ewProfile ?? VirtualTapeEwProfile.EmulatedOverreport(capacity)),
$"OpenVirtualDriveAsync failed: {service.LastError}");
Assert.True(await service.LoadMediaAsync(),
@@ -100,7 +100,7 @@ public async Task ExecuteCalibrateAsync_HonorsAbortRequest()
public async Task ExecuteCalibrateAsync_WithCustomOverreport_ExposesBothOverreportAnchors()
{
var (service, _) = await OpenCalibrationServiceAsync(
- ewProfile: VirtualTapeEwProfile.Lto4Like(
+ ewProfile: VirtualTapeEwProfile.EmulatedOverreport(
CalibrationCapacity, ewZonePercent: 4.0,
phantomFreePercent: 10.0, reportedBoostPercent: 5.0));
diff --git a/TapeLibNET.Tests/VirtualDriveEarlyWarningTests.cs b/TapeLibNET.Tests/VirtualDriveEarlyWarningTests.cs
index bdf18c9..398fb15 100644
--- a/TapeLibNET.Tests/VirtualDriveEarlyWarningTests.cs
+++ b/TapeLibNET.Tests/VirtualDriveEarlyWarningTests.cs
@@ -77,7 +77,7 @@ public void NoProfile_PreservesLegacyExactRemaining()
[Fact]
public void Lto4LikeProfile_RemainingOvershootsThenFloors_EwStickyBeforeEom()
{
- var profile = VirtualTapeEwProfile.Lto4Like(Capacity);
+ var profile = VirtualTapeEwProfile.EmulatedOverreport(Capacity);
using var backend = CreateBackend(profile, report: true);
Assert.Equal(EarlyWarningMechanism.HardwareEarlyWarning, backend.EarlyWarningMechanism);
@@ -140,7 +140,7 @@ public void Lto4LikeProfile_RemainingOvershootsThenFloors_EwStickyBeforeEom()
[Fact]
public void ReportEarlyWarningFalse_SuppressesEwFlag()
{
- var profile = VirtualTapeEwProfile.Lto4Like(Capacity);
+ var profile = VirtualTapeEwProfile.EmulatedOverreport(Capacity);
using var backend = CreateBackend(profile, report: false);
// Mechanism still advertises the zone, but the flag is gated off until reporting is requested.
diff --git a/TapeLibNET/Virtual/VirtualTapeEwProfile.cs b/TapeLibNET/Virtual/VirtualTapeEwProfile.cs
index 2da6558..7b7d84e 100644
--- a/TapeLibNET/Virtual/VirtualTapeEwProfile.cs
+++ b/TapeLibNET/Virtual/VirtualTapeEwProfile.cs
@@ -88,7 +88,7 @@ public bool IsInEarlyWarningZone(long actualWritten, long capacity)
#region *** Factories ***
///
- /// A realistic LTO-4-like preset: an EW zone of of capacity, and a
+ /// An emulated overreport preset: an EW zone of of capacity, and a
/// reported-remaining line pinned by the two independent over-report anchors
/// ().
///
@@ -110,7 +110,7 @@ public bool IsInEarlyWarningZone(long actualWritten, long capacity)
/// the EW zone is orthogonal to them.
///
///
- public static VirtualTapeEwProfile Lto4Like(
+ public static VirtualTapeEwProfile EmulatedOverreport(
long capacity, double ewZonePercent = 4.0, double phantomFreePercent = 4.0,
double reportedBoostPercent = 0.0)
{
diff --git a/TapeWinNET/ViewModels/OpenVirtualDriveViewModel.cs b/TapeWinNET/ViewModels/OpenVirtualDriveViewModel.cs
index 36db576..4be2d09 100644
--- a/TapeWinNET/ViewModels/OpenVirtualDriveViewModel.cs
+++ b/TapeWinNET/ViewModels/OpenVirtualDriveViewModel.cs
@@ -164,7 +164,7 @@ public static IoRateOption FromBytesPerSecond(long bytesPerSecond) =>
///
/// — no profile specified -> do not emulate EW functionality.
/// — the user supplies the EW-zone and capacity-overreport values directly.
-/// — the built-in LTO-4-like preset.
+/// — the built-in emulated overreport preset.
/// Calibration-backed — derived from a stored profile.
///
/// The option leaves the two value inputs editable; all others are opaque and blank the
@@ -182,8 +182,8 @@ public sealed record EwProfileOption(string Display, bool EnableEw, ITapeCalibra
/// The editable "[Custom]" option — values come from the UI, not from this option.
public static EwProfileOption Custom { get; } = new("[Custom]", EnableEw: true, IsCustom: true);
- /// The built-in LTO-4-like preset.
- public static EwProfileOption Lto4 { get; } = new("[LTO-4]", EnableEw: true);
+ /// The built-in emulated overreport preset.
+ public static EwProfileOption Overreport { get; } = new("[Overreport]", EnableEw: true);
/// True when this option carries a stored calibration profile.
public bool IsCalibration => Calibration is not null;
@@ -220,7 +220,7 @@ public sealed record EwProfileOption(string Display, bool EnableEw, ITapeCalibra
if (ewZoneBytes <= 0 && phantomFreeAtEomBytes <= 0 && reportedCapacityBoostBytes <= 0)
return null;
- return VirtualTapeEwProfile.Lto4Like(
+ return VirtualTapeEwProfile.EmulatedOverreport(
capacityBytes,
ewZonePercent: 100.0 * ewZoneBytes / capacityBytes,
phantomFreePercent: 100.0 * phantomFreeAtEomBytes / capacityBytes,
@@ -228,7 +228,7 @@ public sealed record EwProfileOption(string Display, bool EnableEw, ITapeCalibra
}
// Built-in LTO-4 preset (4% EW zone, 4% phantom free at EOM, no BOM boost).
- return VirtualTapeEwProfile.Lto4Like(capacityBytes);
+ return VirtualTapeEwProfile.EmulatedOverreport(capacityBytes);
}
}
diff --git a/TapeWinNET/ViewModels/VirtualDriveConfigViewModelBase.cs b/TapeWinNET/ViewModels/VirtualDriveConfigViewModelBase.cs
index 1005139..869d946 100644
--- a/TapeWinNET/ViewModels/VirtualDriveConfigViewModelBase.cs
+++ b/TapeWinNET/ViewModels/VirtualDriveConfigViewModelBase.cs
@@ -201,7 +201,7 @@ public CapacityUnit InitiatorCapacityUnit
/// calibration profiles are appended by the owning view-model.
///
public ObservableCollection EwProfiles { get; } =
- new([EwProfileOption.None, EwProfileOption.Custom, EwProfileOption.Lto4]);
+ new([EwProfileOption.None, EwProfileOption.Custom, EwProfileOption.Overreport]);
public EwProfileOption SelectedEwProfile
{
From c7e30fde88233d815e224fd52cf0de7f2e044e99 Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Fri, 28 Aug 2026 00:00:22 +0200
Subject: [PATCH 35/37] Ensure correct Early Warning behavior after
repositioning the media, both for physical and virtual drives.
---
.../CalibrationAndLogicalEwTests.cs | 84 +++++++++++++++++-
TapeLibNET/TapeDrive.cs | 53 +++++++++++
TapeLibNET/Virtual/VirtualTapeMedia.EW.cs | 14 +--
TapeLibNET/Virtual/VirtualTapeMedia.cs | 88 ++++++++++++++-----
4 files changed, 210 insertions(+), 29 deletions(-)
diff --git a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
index 55f53a0..4f44139 100644
--- a/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
+++ b/TapeLibNET.Tests/CalibrationAndLogicalEwTests.cs
@@ -366,14 +366,14 @@ public void LogicalEw_AfterPhysicalEw_FiresFromByteCountWithSmallReserve()
// 64 MB cartridge its margin FLOOR (8 MB = 12.5%) dwarfs the 4% physical-EW zone (2.56 MB), so the
// curve trips logical EW well before physical EW is ever seen — correct pessimism, but not the
// after-EW byte-count regime under test.
- var (calDrive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (calDrive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
ITapeCalibration? cal = new TapeCalibrator(calDrive)
{
Options = new TapeCalibrationOptions { SampleCount = 60 },
}.Run();
Assert.NotNull(cal);
- var (drive, _) = CreateDrive(VirtualTapeEwProfile.Lto4Like(Capacity));
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
// A SMALL reserve so logical EW only trips in the precise after-physical-EW byte-count regime.
long reserve = 256L * 1024;
@@ -494,4 +494,84 @@ public void EarlyWarningRuntime_ResetsOnMediaReload()
}
#endregion
+
+ #region *** Re-evaluate Early Warning on Reposition ***
+
+ [Fact]
+ public void EarlyWarning_ClearsOnReposition_OutsideZone()
+ {
+ // Measured calibration so physical EW is actually reached (a-priori's 8 MB margin would pre-empt it).
+ var (calDrive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
+ ITapeCalibration? cal = new TapeCalibrator(calDrive) { Options = new() { SampleCount = 60 } }.Run();
+ Assert.NotNull(cal);
+
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
+ Assert.True(drive.AddCalibration(cal!));
+ Assert.True(drive.SetEarlyWarning(256L * 1024));
+
+ int block = (int)drive.MaximumBlockSize;
+ var data = IncompressibleBlock(block, seed: 41);
+ Assert.True(drive.MoveToPartition(MediaPartition.Content));
+ Assert.True(drive.Rewind());
+
+ while (!drive.IsEarlyWarning)
+ {
+ int n = drive.WriteDirect(data, 0, block, out _, out _, out bool eom);
+ if (eom || n == 0) break;
+ }
+ Assert.True(drive.IsEarlyWarning, "Precondition: EW should latch");
+ Assert.True(drive.IsPhysicalEarlyWarningSeen, "Precondition: physical EW should have fired");
+
+ // Rewind to BOT — before the zone. Sticky + physical anchor clear, and Remaining is now ~full.
+ Assert.True(drive.Rewind());
+ Assert.False(drive.IsEarlyWarning);
+ Assert.False(drive.IsPhysicalEarlyWarningSeen);
+
+ // A fresh overwrite from BOM proceeds a full block — no stale EW, no clamp-to-zero.
+ int written = drive.WriteDirect(data, 0, block, out _, out bool ew, out bool eom2);
+ Assert.Equal(block, written);
+ Assert.False(ew);
+ Assert.False(eom2);
+ Assert.False(drive.IsEarlyWarning);
+ }
+
+ [Fact]
+ public void EarlyWarning_PersistsOnReposition_InsideZone()
+ {
+ var (calDrive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
+ ITapeCalibration? cal = new TapeCalibrator(calDrive) { Options = new() { SampleCount = 60 } }.Run();
+ Assert.NotNull(cal);
+
+ var (drive, _) = CreateDrive(VirtualTapeEwProfile.EmulatedOverreport(Capacity));
+ Assert.True(drive.AddCalibration(cal!));
+ Assert.True(drive.SetEarlyWarning(256L * 1024));
+
+ int block = (int)drive.MaximumBlockSize;
+ var data = IncompressibleBlock(block, seed: 42);
+ Assert.True(drive.MoveToPartition(MediaPartition.Content));
+ Assert.True(drive.Rewind());
+
+ while (!drive.IsEarlyWarning)
+ {
+ int n = drive.WriteDirect(data, 0, block, out _, out _, out bool eom);
+ if (eom || n == 0) break;
+ }
+ Assert.True(drive.IsEarlyWarning, "Precondition: EW should latch");
+ Assert.True(drive.IsPhysicalEarlyWarningSeen, "Precondition: physical EW should have fired");
+
+ // Reposition back a couple of blocks — still deep in the zone (a file-write retry).
+ long backTarget = Math.Max(drive.GetCurrentBlock() - 2, 0);
+ Assert.True(drive.MoveToBlock(backTarget));
+
+ // Derived sticky drops, but the physical anchor is KEPT (we are still past zone entry).
+ Assert.True(drive.IsPhysicalEarlyWarningSeen,
+ "A within-zone reposition must keep the physical EW anchor");
+
+ // Still in the tail ⇒ the next write re-fires logical EW immediately.
+ drive.WriteDirect(data, 0, block, out _, out bool ew, out _);
+ Assert.True(drive.IsEarlyWarning, "Within the zone, the next write must re-fire logical EW");
+ Assert.True(ew);
+ }
+
+ #endregion
}
diff --git a/TapeLibNET/TapeDrive.cs b/TapeLibNET/TapeDrive.cs
index 32f123b..74e04c5 100644
--- a/TapeLibNET/TapeDrive.cs
+++ b/TapeLibNET/TapeDrive.cs
@@ -44,6 +44,7 @@ public class TapeDrive(ILoggerFactory loggerFactory, TapeDriveBackend backend)
// Logical early-warning runtime state, mapped from the backend's physical EW/PEW + calibration.
private bool m_physicalEwSeen = false; // backend reported built-in EW this pass
private long m_ewAnchorBlock = -1L; // drive logical block where physical EW first fired
+ private long m_ewZoneEntryBlock = -1L; // immovable LBA where physical EW first fired (membership test)
private long m_bytesAfterPhysicalEwCarry = 0L; // bytes-after-EW frozen across block-size changes
private long m_bytesSinceRemainingPoll = 0L; // paces the ReportedRemaining poll (approx ok)
// Writable headroom (estimate minus reserve) observed at the last poll. Paces the NEXT poll so the
@@ -446,6 +447,7 @@ public int WriteDirect(byte[] buffer, int offset, int count,
{
m_physicalEwSeen = true;
m_ewAnchorBlock = GetCurrentBlock();
+ m_ewZoneEntryBlock = m_ewAnchorBlock; // ← the fixed zone start (m_ewAnchorBlock may later re-anchor; this does not)
m_bytesAfterPhysicalEwCarry = 0L;
m_logger.LogTrace("{Prefix}: Physical early warning at block {Block}", LogPrefix, m_ewAnchorBlock);
}
@@ -534,11 +536,47 @@ internal void ResetEarlyWarningRuntime()
IsProgrammableEarlyWarning = false;
m_physicalEwSeen = false;
m_ewAnchorBlock = -1L;
+ m_ewZoneEntryBlock = -1L;
m_bytesAfterPhysicalEwCarry = 0L;
m_bytesSinceRemainingPoll = 0L;
m_writableHeadroomAtLastPoll = long.MaxValue;
}
+ ///
+ /// Re-evaluates EW state after a tape REPOSITION, since early warning is a property of physical
+ /// position, not a permanent latch. The logical sticky is derived per-write, so it is always dropped
+ /// here — it re-fires on the next write if we are still in the tail (e.g. a file-write retry a few
+ /// blocks back), and correctly stays clear once the caller has repositioned out of the zone (e.g. a
+ /// rewind to overwrite earlier sets). The PHYSICAL anchor is kept unless we have moved before the
+ /// zone-entry landmark, because losing it would let a later re-detection re-anchor DEEPER and
+ /// over-estimate remaining. Only meaningful on the content partition.
+ ///
+ /// The content-partition logical block the tape was repositioned to.
+ private void ReevaluateEarlyWarningAfterReposition(long newBlock)
+ {
+ // Drop the derived logical sticky on ANY reposition; it is recomputed on the next write.
+ IsEarlyWarning = false;
+ IsProgrammableEarlyWarning = false;
+
+ // Physical zone membership: only when we have moved BEFORE where physical EW first fired do we
+ // leave the zone and shed the anchor + accounting. A within-zone move (retry) keeps them, so
+ // BytesAfterPhysicalEw() recomputes correctly from the new, earlier-but-still-in-zone position.
+ // The zone-entry LBA is immovable across block-size changes, so this comparison is robust.
+ if (m_physicalEwSeen && m_ewZoneEntryBlock >= 0L && newBlock < m_ewZoneEntryBlock)
+ {
+ m_physicalEwSeen = false;
+ m_ewAnchorBlock = -1L;
+ m_ewZoneEntryBlock = -1L;
+ m_bytesAfterPhysicalEwCarry = 0L;
+
+ // Left the zone → reset the pre-EW curve-poll pacing to a clean state. (A within-zone move
+ // deliberately KEEPS the cached headroom so ClampWriteToEarlyWarning stays armed for the
+ // very next write, and keeps the poll counter high so that write re-polls immediately.)
+ m_bytesSinceRemainingPoll = 0L;
+ m_writableHeadroomAtLastPoll = long.MaxValue;
+ }
+ }
+
///
/// Requests an early-warning reserve of bytes before EOM.
/// ALWAYS honored while media is loaded: TapeDrive selects the best available mechanism —
@@ -1112,7 +1150,10 @@ public bool MoveToPartition(MediaPartition partition, long block = 0)
InvalidateMediaParams(keepBlockSize: false);
if (m_onContentPartition)
+ {
CacheContentMediaParams(EnsureMediaParams()); // refresh content capacity cache asap
+ ReevaluateEarlyWarningAfterReposition(block);
+ }
m_logger.LogTrace("{Prefix}: Moved to partition {Partition}", LogPrefix, partition);
return true;
@@ -1254,6 +1295,10 @@ public bool Rewind()
return false;
}
+ InvalidateMediaParams(keepBlockSize: true); // position changed ⇒ Remaining must be re-read
+ if (m_onContentPartition)
+ ReevaluateEarlyWarningAfterReposition(0L);
+
m_logger.LogTrace("{Prefix}: Rewound", LogPrefix);
return true;
}
@@ -1271,6 +1316,10 @@ public bool FastforwardToEnd(MediaPartition partition = MediaPartition.Content)
return false;
}
+ InvalidateMediaParams(keepBlockSize: true); // position changed ⇒ Remaining must be re-read
+ // No need to call ReevaluateEarlyWarningAfterReposition(): moving toward EOM never leaves the zone;
+ // and if it re-enters, the next write senses it!
+
m_logger.LogTrace("{Prefix}: Fast forwarded to end", LogPrefix);
return true;
}
@@ -1297,6 +1346,10 @@ public bool MoveToBlock(long block)
return false;
}
+ InvalidateMediaParams(keepBlockSize: true); // position changed ⇒ Remaining must be re-read
+ if (m_onContentPartition)
+ ReevaluateEarlyWarningAfterReposition(block);
+
m_logger.LogTrace("{Prefix}: Moved to block {Block}", LogPrefix, block);
return true;
}
diff --git a/TapeLibNET/Virtual/VirtualTapeMedia.EW.cs b/TapeLibNET/Virtual/VirtualTapeMedia.EW.cs
index 1edcf85..389dfa2 100644
--- a/TapeLibNET/Virtual/VirtualTapeMedia.EW.cs
+++ b/TapeLibNET/Virtual/VirtualTapeMedia.EW.cs
@@ -20,24 +20,26 @@ public partial class VirtualTapeMedia
internal VirtualTapeEwProfile? EwProfile { get; set; }
///
- /// The TRUE bytes still writable before hard EOM (capacity − bytesWritten, floored at zero).
+ /// The TRUE bytes still writable before hard EOM (capacity − current_position_bytes, floored at zero).
/// This is the authoritative figure for capacity enforcement, independent of any reporting model.
///
- public long TrueRemaining => System.Math.Max(0L, m_capacity - m_bytesWritten);
+ public long TrueRemaining => Math.Max(0L, m_capacity - CurrentPositionBytes());
///
/// The remaining figure the emulated driver reports — the (optionally optimistic) model value when an
/// is configured, otherwise the exact .
///
private long ReportedRemaining()
- => EwProfile?.ReportedRemaining(m_bytesWritten, m_capacity) ?? TrueRemaining;
+ => EwProfile?.ReportedRemaining(CurrentPositionBytes(), m_capacity) ?? TrueRemaining;
///
- /// Whether the current true position lies within the configured early-warning zone. False when no
- /// profile (or a zero-width zone) is configured. Monotonic: once entered, stays true up to hard EOM.
+ /// Whether the current true POSITION lies within the configured early-warning zone. False when no
+ /// profile (or a zero-width zone) is configured. Position-based (not odometer-based): re-reads false
+ /// after a backward seek out of the zone, and true again once the head re-enters the tail — matching
+ /// real drive behavior and the position-based .
///
public bool IsInEarlyWarningZone
- => EwProfile?.IsInEarlyWarningZone(m_bytesWritten, m_capacity) ?? false;
+ => EwProfile?.IsInEarlyWarningZone(CurrentPositionBytes(), m_capacity) ?? false;
#endregion
}
diff --git a/TapeLibNET/Virtual/VirtualTapeMedia.cs b/TapeLibNET/Virtual/VirtualTapeMedia.cs
index bea3f89..5992ec2 100644
--- a/TapeLibNET/Virtual/VirtualTapeMedia.cs
+++ b/TapeLibNET/Virtual/VirtualTapeMedia.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
+using System.Diagnostics;
using Windows.Win32.Foundation;
namespace TapeLibNET.Virtual;
@@ -343,6 +344,15 @@ public uint BlockSize
/// Whether state has been modified since last save.
public bool IsStateDirty => m_stateDirty;
+ ///
+ /// Validate the invariant the " == ".
+ /// Recommended after operations modifying media content. Only active in DEBUG builds.
+ ///
+ [Conditional("DEBUG")]
+ private void AssertByteTotalConsistent() =>
+ Debug.Assert(m_bytesWritten == CalculateStreamLength(),
+ $"m_bytesWritten {m_bytesWritten} != CalculateStreamLength {CalculateStreamLength()}");
+
#endregion
#region *** Block Size Management ***
@@ -465,6 +475,8 @@ public int WriteBlocks(byte[] buffer, int offset, int count)
LogErrorAsDebug("Stream write failed");
}
+ AssertByteTotalConsistent();
+
return totalWritten;
}
@@ -500,6 +512,9 @@ public bool WriteMark(TapeMarkType markType)
m_currentVirtualBlockIndex = m_virtualBlocks.Count; // Point past end
m_currentBlock++;
m_stateDirty = true;
+
+ AssertByteTotalConsistent();
+
return true;
}
@@ -1025,6 +1040,7 @@ private void SyncVirtualBlockIndex()
///
/// Finds the virtual block index that contains the given logical block.
/// Returns m_virtualBlocks.Count if block is at or past end.
+ /// Complexity: O(log n) thanks to binary search.
///
private int FindVirtualBlockIndex(long logicalBlock)
{
@@ -1072,6 +1088,38 @@ private int FindVirtualBlockIndexBefore(long logicalBlock)
return -1;
}
+ ///
+ /// Data bytes between BOM and the current logical position — i.e. physical tape consumed "behind" the
+ /// head. Basis for a POSITION-based Remaining ("bytes between the current position and EOT", per the
+ /// Win32 TAPE_GET_MEDIA_PARAMETERS contract), NOT the odometer m_bytesWritten. They coincide while
+ /// appending (position == EOD), and diverge exactly after a rewind/seek — where position is correct:
+ /// a rewound tape can be overwritten to its full capacity, so it must report ~full remaining.
+ ///
+ private long CurrentPositionBytes()
+ {
+ if (m_virtualBlocks.Count == 0 || m_currentBlock <= 0)
+ return 0;
+ if (m_currentBlock >= TotalBlockCount)
+ return m_bytesWritten; // at EOD: all data is behind the head
+
+ int i = FindVirtualBlockIndex(m_currentBlock); // existing binary search — O(log n)
+ var vb = m_virtualBlocks[i];
+
+ // Inside (or at the start of) a data block: StreamOffset is the prefix sum of all prior data bytes
+ // (data is contiguous in the stream, in block order; marks add nothing). BeginAtBlock is contained,
+ // so a head sitting exactly at the block start correctly yields StreamOffset + 0.
+ if (!vb.IsMark && vb.ContainsBlock(m_currentBlock))
+ return vb.StreamOffset + (m_currentBlock - vb.BeginAtBlock) * vb.BlockSize;
+
+ // Head sits on a MARK: the prefix sum is where the surrounding data ends. Walk back to the nearest
+ // data block (marks are isolated in practice, so this is effectively O(1)).
+ for (int j = i - 1; j >= 0; j--)
+ if (!m_virtualBlocks[j].IsMark)
+ return m_virtualBlocks[j].StreamOffset + m_virtualBlocks[j].DataLength;
+
+ return 0;
+ }
+
///
/// Truncates all data from the current logical block position onwards.
/// Handles splitting a virtual block if current position is inside it.
@@ -1079,54 +1127,52 @@ private int FindVirtualBlockIndexBefore(long logicalBlock)
private void TruncateFromCurrentPosition()
{
SyncVirtualBlockIndex();
-
if (m_currentVirtualBlockIndex >= m_virtualBlocks.Count)
- return; // At end, nothing to truncate
+ return; // At EOD — nothing to truncate; m_bytesWritten already correct.
+
+ // The data BEHIND the head is exactly what survives, so it IS the new total. Capture it BEFORE
+ // mutating the block list, then assign it authoritatively — replacing the fragile per-branch
+ // delta-subtraction, self-correcting any prior drift, and staying consistent with the
+ // position-based CurrentPositionBytes() used everywhere else.
+ long survivingBytes = CurrentPositionBytes();
var vb = m_virtualBlocks[m_currentVirtualBlockIndex];
- // Check if we're in the middle of a data virtual block (need to split)
if (!vb.IsMark && vb.ContainsBlock(m_currentBlock) && m_currentBlock > vb.BeginAtBlock)
{
- // Calculate bytes being removed from this block
- long bytesRemoved = (vb.EndBlock - m_currentBlock) * vb.BlockSize;
- m_bytesWritten -= bytesRemoved;
-
- // Truncate this virtual block
- var truncated = vb.TruncateAt(m_currentBlock);
- m_virtualBlocks[m_currentVirtualBlockIndex] = truncated;
-
- // Remove all subsequent virtual blocks
+ // Head inside a data block → split at the head, drop everything after.
+ m_virtualBlocks[m_currentVirtualBlockIndex] = vb.TruncateAt(m_currentBlock);
RemoveVirtualBlocksFrom(m_currentVirtualBlockIndex + 1);
}
else if (vb.BeginAtBlock == m_currentBlock)
{
- // Current position is at the start of a virtual block - remove it and all following
+ // Head at a block boundary → drop this block and all following.
RemoveVirtualBlocksFrom(m_currentVirtualBlockIndex);
}
else
LogErrorAsDebug("Unexpected: current block points past last virtual block");
- // Truncate stream to match
+ m_bytesWritten = survivingBytes;
TruncateStream();
m_stateDirty = true;
+
+ AssertByteTotalConsistent();
}
///
/// Removes all virtual blocks starting from the given index.
+ ///
+ /// NOTE: Does NOT update — caller must do so based
+ /// on or other logic.
+ ///
///
private void RemoveVirtualBlocksFrom(int startIndex)
{
if (startIndex >= m_virtualBlocks.Count)
return;
- // Subtract bytes from all removed data blocks
- for (int i = startIndex; i < m_virtualBlocks.Count; i++)
- {
- if (!m_virtualBlocks[i].IsMark)
- m_bytesWritten -= m_virtualBlocks[i].DataLength;
- }
-
+ // Structural removal only — TruncateFromCurrentPosition now owns m_bytesWritten (set from
+ // CurrentPositionBytes()), so there is no byte bookkeeping here to drift.
m_virtualBlocks.RemoveRange(startIndex, m_virtualBlocks.Count - startIndex);
m_stateDirty = true;
}
From 97818904e608ca4039014f55333ee7092b3958be Mon Sep 17 00:00:00 2001
From: Alex K
Date: Fri, 28 Aug 2026 17:41:36 +0200
Subject: [PATCH 36/37] Update Remaining and Early Warning accounting on tape
mark navigation.
---
TapeLibNET/TapeDrive.cs | 38 ++++++++++++++++++++++++--------------
1 file changed, 24 insertions(+), 14 deletions(-)
diff --git a/TapeLibNET/TapeDrive.cs b/TapeLibNET/TapeDrive.cs
index 74e04c5..89d8421 100644
--- a/TapeLibNET/TapeDrive.cs
+++ b/TapeLibNET/TapeDrive.cs
@@ -1176,6 +1176,8 @@ public bool MoveToNextFilemark(int count = 1)
return false;
}
+ OnPositionChanged(backwards: count < 0);
+
m_logger.LogTrace("{Prefix}: Moved by {Count} filemark(s)", LogPrefix, count);
return true;
}
@@ -1193,8 +1195,6 @@ public bool WriteFilemark(uint count = 1)
return false;
}
- InvalidateMediaParams(keepBlockSize: true); // filemark may have changed the position
-
m_logger.LogTrace("{Prefix}: Wrote {Count} filemark(s)", LogPrefix, count);
return true;
}
@@ -1212,6 +1212,8 @@ public bool MovePastSeqFilemarks(int count)
return false;
}
+ OnPositionChanged(backwards: count < 0);
+
m_logger.LogTrace("{Prefix}: Moved past {Count} seq filemark(s)", LogPrefix, count);
return true;
}
@@ -1235,6 +1237,8 @@ public bool MoveToNextSetmark(int count = 1)
return false;
}
+ OnPositionChanged(backwards: count < 0);
+
m_logger.LogTrace("{Prefix}: Moved by {Count} setmark(s)", LogPrefix, count);
return true;
}
@@ -1252,8 +1256,6 @@ public bool WriteSetmark(uint count = 1)
return false;
}
- InvalidateMediaParams(keepBlockSize: true); // setmark may have changed the position
-
m_logger.LogTrace("{Prefix}: Wrote {Count} setmark(s)", LogPrefix, count);
return true;
}
@@ -1295,9 +1297,7 @@ public bool Rewind()
return false;
}
- InvalidateMediaParams(keepBlockSize: true); // position changed ⇒ Remaining must be re-read
- if (m_onContentPartition)
- ReevaluateEarlyWarningAfterReposition(0L);
+ OnPositionChanged(backwards: true);
m_logger.LogTrace("{Prefix}: Rewound", LogPrefix);
return true;
@@ -1316,9 +1316,7 @@ public bool FastforwardToEnd(MediaPartition partition = MediaPartition.Content)
return false;
}
- InvalidateMediaParams(keepBlockSize: true); // position changed ⇒ Remaining must be re-read
- // No need to call ReevaluateEarlyWarningAfterReposition(): moving toward EOM never leaves the zone;
- // and if it re-enters, the next write senses it!
+ OnPositionChanged(backwards: false);
m_logger.LogTrace("{Prefix}: Fast forwarded to end", LogPrefix);
return true;
@@ -1330,7 +1328,9 @@ public bool MoveToBlock(long block)
if (!IsMediaLoaded)
return false;
- if (block == BlockCounter)
+ long currBlock = BlockCounter;
+
+ if (block == currBlock)
return true;
if (block < 0)
@@ -1346,9 +1346,7 @@ public bool MoveToBlock(long block)
return false;
}
- InvalidateMediaParams(keepBlockSize: true); // position changed ⇒ Remaining must be re-read
- if (m_onContentPartition)
- ReevaluateEarlyWarningAfterReposition(block);
+ OnPositionChanged(backwards: block < currBlock, toBlock: block);
m_logger.LogTrace("{Prefix}: Moved to block {Block}", LogPrefix, block);
return true;
@@ -1433,6 +1431,17 @@ private void InvalidateContentCache()
m_onContentPartition = false;
}
+ private void OnPositionChanged(bool backwards, long toBlock = -1)
+ {
+ InvalidateMediaParams(keepBlockSize: true); // position changed ⇒ Remaining must be re-read
+ if (m_onContentPartition && backwards) // we moved backwards ⇒ might've left the EW zone!
+ {
+ if (toBlock < 0)
+ toBlock = BlockCounter;
+ ReevaluateEarlyWarningAfterReposition(toBlock);
+ }
+ }
+
///
/// Ensures the drive is on the Content partition and the capacity cache is populated.
/// For multi-partition media, checks actual position first to avoid an unnecessary move.
@@ -1514,4 +1523,5 @@ private void SetOptimalMediaParams()
}
#endregion // *** Private Helpers ***
+
} // class TapeDrive
From 967ab5c72304b6d7a0c136b10d6209486b9088f9 Mon Sep 17 00:00:00 2001
From: avkl1m
Date: Fri, 28 Aug 2026 17:42:43 +0200
Subject: [PATCH 37/37] Implement "MediaId" (Guid) for tape TOC.
---
TapeLibNET.Tests/TapeTOCRoundTripTests.cs | 124 +++++++++++++++++++-
TapeLibNET/Services/TapeServiceBase.List.cs | 2 +
TapeLibNET/Services/TapeServiceBase.cs | 2 +
TapeLibNET/TapeAgent.cs | 6 +
TapeLibNET/TapeSerializer.cs | 21 ++++
TapeLibNET/TapeTOC.cs | 87 +++++++++++++-
TapeWinNET/ViewModels/MainViewModel.cs | 2 +
7 files changed, 234 insertions(+), 10 deletions(-)
diff --git a/TapeLibNET.Tests/TapeTOCRoundTripTests.cs b/TapeLibNET.Tests/TapeTOCRoundTripTests.cs
index 3730028..11be332 100644
--- a/TapeLibNET.Tests/TapeTOCRoundTripTests.cs
+++ b/TapeLibNET.Tests/TapeTOCRoundTripTests.cs
@@ -24,7 +24,6 @@ public class TapeTOCRoundTripTests
#region *** Test Data ***
/// All three drive profiles for parameterized theories.
-#pragma warning disable CA1825 // Avoid zero-length array allocations
public static TheoryData AllProfiles =>
[
DriveProfile.Setmarks,
@@ -32,19 +31,16 @@ public class TapeTOCRoundTripTests
DriveProfile.SeqFilemarks,
DriveProfile.FilemarksOnly,
];
-#pragma warning restore CA1825 // Avoid zero-length array allocations
///
/// Profiles that can save/restore TOC on an empty tape (no prior content).
/// SeqFilemarks excluded: its navigator requires existing TOC markers on tape.
///
-#pragma warning disable CA1825 // Avoid zero-length array allocations
public static TheoryData ProfilesWithTOCOnEmptyTape =>
[
DriveProfile.Setmarks,
DriveProfile.Partitions,
];
-#pragma warning restore CA1825 // Avoid zero-length array allocations
#endregion
@@ -235,6 +231,7 @@ private static void AssertSetTOCEqual(TapeSetTOC expected, TapeSetTOC actual)
///
private static void AssertTOCEqual(TapeTOC expected, TapeTOC actual)
{
+ Assert.Equal(expected.MediaId, actual.MediaId);
Assert.Equal(expected.Description, actual.Description);
Assert.Equal(expected.CreationTime, actual.CreationTime);
Assert.Equal(expected.Volume, actual.Volume);
@@ -522,6 +519,125 @@ public void TapeSetTOC_AllHashAlgorithms_RoundTrip()
#endregion
+ #region *** MediaId (Guid) ***
+
+ [Fact]
+ public void TapeTOC_MediaId_RoundTrip()
+ {
+ var toc = BuildComplexTOC(2, 3, description: "Identity");
+
+ toc.EnsureMediaId(); // mint an id
+ var id = toc.MediaId;
+ Assert.NotEqual(Guid.Empty, id);
+
+ var result = SerializeAndDeserialize(toc);
+ Assert.Equal(id, result.MediaId); // survives the round-trip
+ }
+
+ [Fact]
+ public void TapeTOC_MediaId_DefaultsEmpty_WhenNeverMinted()
+ {
+ // A TOC that was never written keeps Guid.Empty through serialization.
+ var toc = BuildComplexTOC(1, 2, description: "No Identity Yet");
+
+ var result = SerializeAndDeserialize(toc);
+ Assert.Equal(Guid.Empty, result.MediaId);
+ }
+
+ [Fact]
+ public void TapeTOC_EnsureMediaId_IsIdempotent()
+ {
+ var toc = new TapeTOC("Once Only");
+
+ var first = toc.EnsureMediaId();
+ var second = toc.EnsureMediaId(); // must not re-mint
+
+ Assert.Equal(first, second);
+ Assert.NotEqual(Guid.Empty, first);
+ }
+
+ [Fact]
+ public void TapeTOC_CopyFrom_PreservesMediaId()
+ {
+ var original = BuildComplexTOC(2, 2, description: "Source");
+ original.EnsureMediaId();
+
+ var copy = new TapeTOC();
+ copy.CopyFrom(original);
+
+ Assert.Equal(original.MediaId, copy.MediaId);
+ }
+
+ ///
+ /// Builds a pre-MediaId (TocVersionInitial / 0x0101) on-tape image of an EMPTY TOC:
+ /// identical to the current layout but WITHOUT the MediaId field and with the old
+ /// version in the signature. An empty set list serializes as just its count (0),
+ /// so we can hand-write the whole stream with serializer primitives.
+ ///
+ private static byte[] BuildLegacyEmptyTOCBytes(
+ ulong nextUID, string description, DateTime creation, DateTime lastSave,
+ int volume, bool continued)
+ {
+ using var ms = new MemoryStream();
+ var s = new TapeSerializer(ms);
+
+ s.SerializeSignature(TapeTOC.TocVersionInitial); // 0x0101 — no MediaId follows
+
+ s.Serialize(nextUID);
+ s.Serialize(0); // setTOCs: empty list == count 0 (NO MediaId before it)
+ s.Serialize(description);
+ s.Serialize(creation);
+ s.Serialize(lastSave);
+ s.Serialize(volume);
+ s.Serialize(continued);
+
+ return ms.ToArray();
+ }
+
+ [Fact]
+ public void TapeTOC_LegacyVersion_ReadsWithEmptyMediaId()
+ {
+ var creation = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Local);
+ var lastSave = new DateTime(2020, 1, 2, 0, 0, 0, DateTimeKind.Local);
+
+ var bytes = BuildLegacyEmptyTOCBytes(
+ nextUID: 5UL, description: "Legacy Media",
+ creation: creation, lastSave: lastSave, volume: 2, continued: true);
+
+ using var ms = new MemoryStream(bytes);
+ var toc = new TapeDeserializer(ms).Deserialize();
+
+ Assert.NotNull(toc);
+
+ // The crucial guarantee: the legacy stream reads back with NO identity and the
+ // trailing fields stay aligned (no phantom 16-byte Guid read corrupting them).
+ Assert.Equal(Guid.Empty, toc!.MediaId);
+ Assert.Equal("Legacy Media", toc.Description);
+ Assert.Equal(2, toc.Volume);
+ Assert.True(toc.ContinuedOnNextVolume);
+
+ // UID continuity intact — proves stream position landed correctly.
+ Assert.Equal(5UL, toc.GenerateUID());
+ }
+
+ [Theory]
+ [MemberData(nameof(ProfilesWithTOCOnEmptyTape))]
+ public void OnTape_MediaId_MintedOnFirstSave_PersistsAcrossReload(DriveProfile profile)
+ {
+ using var fixture = new VirtualTapeFixture(profile);
+
+ // A fresh in-memory TOC has no identity yet.
+ Assert.Equal(Guid.Empty, fixture.TOC.MediaId);
+
+ fixture.SaveTOC(); // first durable write mints the id
+ var minted = fixture.TOC.MediaId;
+ Assert.NotEqual(Guid.Empty, minted);
+
+ fixture.LoadTOC(); // reload from tape
+ Assert.Equal(minted, fixture.TOC.MediaId); // identity survives the round-trip
+ }
+
+ #endregion
#region *** TapeTOC — In-Memory Serialization ***
diff --git a/TapeLibNET/Services/TapeServiceBase.List.cs b/TapeLibNET/Services/TapeServiceBase.List.cs
index 2fda80b..861b3d5 100644
--- a/TapeLibNET/Services/TapeServiceBase.List.cs
+++ b/TapeLibNET/Services/TapeServiceBase.List.cs
@@ -299,6 +299,8 @@ protected virtual void LogMediaInfoFull()
return;
LogInfoSub($"Name: >{toc.Description}<");
+ if (toc.MediaId != Guid.Empty)
+ LogInfoSub($"Media ID: {toc.MediaId}");
LogInfoSub($"Created on: {toc.CreationTime}");
LogInfoSub($"Last saved: {toc.LastSaveTime}");
LogInfoSub($"Backup sets: {toc.Count}");
diff --git a/TapeLibNET/Services/TapeServiceBase.cs b/TapeLibNET/Services/TapeServiceBase.cs
index bd92132..61e44cd 100644
--- a/TapeLibNET/Services/TapeServiceBase.cs
+++ b/TapeLibNET/Services/TapeServiceBase.cs
@@ -887,6 +887,8 @@ protected virtual void LogTOCInfo()
{
if (_toc is null) return;
LogInfoSub($"Media name: {_toc.Description}");
+ if (_toc.MediaId != Guid.Empty)
+ LogInfoSub($"Media ID: {_toc.MediaId}");
LogInfoSub($"Created: {_toc.CreationTime}");
LogInfoSub($"Last saved: {_toc.LastSaveTime}");
LogInfoSub($"Volume: #{_toc.Volume}");
diff --git a/TapeLibNET/TapeAgent.cs b/TapeLibNET/TapeAgent.cs
index ebfc996..bcc5c06 100644
--- a/TapeLibNET/TapeAgent.cs
+++ b/TapeLibNET/TapeAgent.cs
@@ -432,6 +432,12 @@ private bool BackupTOCCore()
/// (use after operations that may leave the tape position uncertain).
public TapeResult BackupTOC(bool enforce = false)
{
+ // We stamp a stable media identity before the first durable write. New media (just
+ // formatted) and legacy Guid-less media (just loaded) both reach here with an empty
+ // MediaId; we mint once, then it persists across every rewrite and across all volumes.
+ // Both TOC copies serialize the same value, since we mint into the in-memory TOC here.
+ TOC.EnsureMediaId();
+
#if DEBUG
_tocCopyCounter = 0;
#endif
diff --git a/TapeLibNET/TapeSerializer.cs b/TapeLibNET/TapeSerializer.cs
index aa65ba5..944353d 100644
--- a/TapeLibNET/TapeSerializer.cs
+++ b/TapeLibNET/TapeSerializer.cs
@@ -54,6 +54,14 @@ public void Serialize(TUnmanaged value) where TUnmanaged: unmanaged
public void Serialize(FileAttributes attr) => Serialize((uint)attr);
public void Serialize(TapeAddress addr) { Serialize(addr.Block); Serialize(addr.Offset); }
public void Serialize(DateTime dt) => Serialize(dt.Ticks);
+ ///
+ /// Serializes a as its canonical 16-byte representation.
+ ///
+ /// Uses (not the generic unmanaged path) so the on-tape
+ /// layout stays defined and stable across runtimes.
+ ///
+ ///
+ public void Serialize(Guid guid) => Serialize(guid.ToByteArray());
public void Serialize(TapeFileDescriptor fileDescr)
{
// serialize all settable public properties
@@ -133,6 +141,19 @@ public string DeserializeString()
public FileAttributes DeserializeFileAttributes() => (FileAttributes)DeserializeUInt32();
public DateTime DeserializeDateTime() => new(DeserializeInt64());
+ ///
+ /// Deserializes a written by
+ /// — a fixed 16-byte block.
+ ///
+ /// Thrown when fewer than 16 bytes remain.
+ public Guid DeserializeGuid()
+ {
+ var bytes = DeserializeBytes(16);
+
+ return (bytes != null)
+ ? new Guid(bytes)
+ : throw new FormatException("Error deserializing Guid");
+ }
public TapeAddress DeserializeTapeAddress() => new(DeserializeInt64(), DeserializeUInt32());
public TapeFileDescriptor DeserializeFileDescriptor()
{
diff --git a/TapeLibNET/TapeTOC.cs b/TapeLibNET/TapeTOC.cs
index c82181c..de1d775 100644
--- a/TapeLibNET/TapeTOC.cs
+++ b/TapeLibNET/TapeTOC.cs
@@ -644,6 +644,20 @@ public class TapeTOC : ITapeSerializable, IEnumerable
private readonly List m_setTOCs;
private TypeUID m_nextUID;
+ ///
+ /// On-tape format version for the record specifically, kept
+ /// independent of the library-wide so the TOC
+ /// layout can evolve without invalidating every other serialized type's signature.
+ /// Legacy TOCs — written before per-record versioning — carry the then-current
+ /// library version (0x0101) in their signature; that value doubles as our
+ /// "initial, pre-MediaId" TOC version.
+ ///
+ public const ushort TocVersionInitial = 0x0101;
+ /// TOC format version that introduced the field.
+ public const ushort TocVersionWithMediaId = 0x0102;
+ /// Current TOC format version written by this build.
+ public const ushort TocVersion = TocVersionWithMediaId;
+
public TapeTOC()
{
m_setTOCs = [];
@@ -653,6 +667,11 @@ public TapeTOC(string description) : this()
{
Description = description;
}
+ ///
+ /// Copy constructor: clones the given into a new instance.
+ /// Calls .
+ ///
+ /// The instance to copy.
public TapeTOC(TapeTOC toc) : this()
{
CopyFrom(toc);
@@ -660,6 +679,31 @@ public TapeTOC(TapeTOC toc) : this()
internal TypeUID GenerateUID() => m_nextUID++;
public string Description { get; set; } = string.Empty;
+ ///
+ /// Stable identity of the media — or, for a multi-volume backup, of the whole volume
+ /// series, since every volume of one series shares this id. Minted once on the first
+ /// durable TOC write (see ) and never altered afterwards.
+ /// means "not yet assigned": a legacy Guid-less TOC just
+ /// read from tape, or a brand-new in-memory TOC not yet written. Read by apps for
+ /// catalog keys, log correlation, .tapetoc↔cartridge matching, and wrong-media guards;
+ /// writable only inside TapeLibNET.
+ ///
+ public Guid MediaId { get; internal set; } = Guid.Empty;
+ ///
+ /// Guarantees a non-empty : mints a fresh when
+ /// none exists, otherwise leaves the current identity intact. Called on the first durable
+ /// TOC write, so freshly formatted media and legacy Guid-less media both acquire a
+ /// permanent id — one that then rides unchanged across every rewrite and across all
+ /// volumes of a multi-volume series.
+ ///
+ /// The effective (possibly newly minted) .
+ internal Guid EnsureMediaId()
+ {
+ if (MediaId == Guid.Empty)
+ MediaId = Guid.NewGuid();
+
+ return MediaId;
+ }
public DateTime CreationTime { get; internal set; } = DateTime.Now;
public DateTime LastSaveTime { get; internal set; } = DateTime.Now;
@@ -697,7 +741,9 @@ private int SetIndexToInternal(int setIndex)
return setIndex - 1;
}
private static int InternalToSetIndex(int setInternal) => setInternal + 1;
- // convert standard index to alternative one, or vice versa
+ /// Convert standard index to alternative one, or vice versa.
+ /// Index in standard (1..N) or alternative (−(N−1)..0) format.
+ /// The converted index.
public int SetIndexToAlt(int setIndex)
{
if (setIndex <= 0)
@@ -856,7 +902,10 @@ public void AddContinuationSetTOC(TapeSetTOCParams setParams, bool contFromPrevV
MakeLastSetCurrent();
}
- /// Deep-copies all content from , replacing everything in this instance.
+ ///
+ /// Deep-copies all content from , replacing everything in this instance.
+ /// Used by the copy constructor
+ ///
public void CopyFrom(TapeTOC toc) // Replaces the whole content -> use with CAUTION!
{
m_setTOCs.Clear();
@@ -864,6 +913,12 @@ public void CopyFrom(TapeTOC toc) // Replaces the whole content -> use with CAUT
m_setTOCs.Add(new TapeSetTOC(setTOC));
m_nextUID = toc.m_nextUID;
+
+ // Preserve the media identity across copies and restores — RestoreTOCCore copies the
+ // freshly deserialized TOC into the live one via CopyFrom, so dropping this line would
+ // silently strip the id from every loaded TOC.
+ MediaId = toc.MediaId;
+
Description = toc.Description;
CreationTime = toc.CreationTime;
LastSaveTime = toc.LastSaveTime;
@@ -928,9 +983,17 @@ private TapeTOC(TypeUID nextUID, List setTOCs)
public void SerializeTo(TapeSerializer serializer)
{
- serializer.SerializeSignature();
+ // Write our own TOC-specific version (decoupled from the library-wide
+ // TapeSerializer.Version) so the TOC layout can grow without disturbing
+ // any other serialized type's signature.
+ serializer.SerializeSignature(TocVersion);
serializer.Serialize((ulong)m_nextUID);
+
+ // MediaId sits early — right after the UID — so a future lightweight reader can
+ // peek the series identity without deserializing the whole set list.
+ serializer.Serialize(MediaId);
+
serializer.Serialize, TapeSetTOC>(m_setTOCs);
serializer.Serialize(Description);
serializer.Serialize(CreationTime);
@@ -941,17 +1004,29 @@ public void SerializeTo(TapeSerializer serializer)
public static ITapeSerializable? ConstructFrom(TapeDeserializer deserializer)
{
- if (!deserializer.ValidateSignature())
- return null;
+ // Tolerant read: capture the on-tape version instead of demanding an exact match,
+ // so this build reads both legacy (pre-MediaId) and current TOCs. The nested
+ // TapeSetTOC / TapeFileInfo records keep their strict signature check, unaffected.
+ if (!deserializer.ValidateSignature(out ushort version))
+ return null; // signature bytes don't match -> not a TOC record
TypeUID nextUID = (TypeUID)deserializer.DeserializeUInt64();
if (nextUID == 0UL) // invalid UID
return null;
+ // MediaId appears only from TocVersionWithMediaId onward. Older TOCs never wrote it,
+ // so default to Guid.Empty ("unidentified legacy media") — it gets minted on the next
+ // durable write. Reading it here, before the set list, matches the write order and
+ // keeps the trailing fields (and the caller's appended CRC) correctly aligned.
+ Guid mediaId = (version >= TocVersionWithMediaId)
+ ? deserializer.DeserializeGuid()
+ : Guid.Empty;
+
var setTOCs = deserializer.Deserialize, TapeSetTOC>();
return new TapeTOC(nextUID, setTOCs)
{
+ MediaId = mediaId,
Description = deserializer.DeserializeString(),
CreationTime = deserializer.DeserializeDateTime(),
LastSaveTime = deserializer.DeserializeDateTime(),
@@ -959,7 +1034,7 @@ public void SerializeTo(TapeSerializer serializer)
ContinuedOnNextVolume = deserializer.DeserializeBoolean(),
};
}
-
+
#endregion // ITapeSerializable
diff --git a/TapeWinNET/ViewModels/MainViewModel.cs b/TapeWinNET/ViewModels/MainViewModel.cs
index b6a820c..1ad6bf4 100644
--- a/TapeWinNET/ViewModels/MainViewModel.cs
+++ b/TapeWinNET/ViewModels/MainViewModel.cs
@@ -1573,6 +1573,8 @@ private void LoadMediaInfo()
// Populate media properties
PropertyList.Add(new PropertyItem("Description", toc.Description ?? "(unnamed)"));
+ if (toc.MediaId != Guid.Empty)
+ PropertyList.Add(new PropertyItem("Media ID", toc.MediaId.ToString()));
PropertyList.Add(new PropertyItem("Created On", toc.CreationTime.ToString("G")));
PropertyList.Add(new PropertyItem("Last Saved", toc.LastSaveTime.ToString("G")));
PropertyList.Add(new PropertyItem("Backup Sets", toc.Count.ToString()));