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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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()));