diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss
index f7ae52d..2640a7e 100644
--- a/installer/PSLauncher.iss
+++ b/installer/PSLauncher.iss
@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher"
-#define MyAppVersion "0.28.10"
+#define MyAppVersion "0.28.11"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe"
diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj
index 4d65a25..241b974 100644
--- a/src/PSLauncher.App/PSLauncher.App.csproj
+++ b/src/PSLauncher.App/PSLauncher.App.csproj
@@ -18,9 +18,9 @@
PROSERVE Launcher
© 2026 ASTERION VR — All rights reserved
PSLauncher.App
- 0.28.10
- 0.28.10.0
- 0.28.10.0
+ 0.28.11
+ 0.28.11.0
+ 0.28.11.0
true
diff --git a/src/PSLauncher.Core/Downloads/DownloadManager.cs b/src/PSLauncher.Core/Downloads/DownloadManager.cs
index a5696a7..5aa35d0 100644
--- a/src/PSLauncher.Core/Downloads/DownloadManager.cs
+++ b/src/PSLauncher.Core/Downloads/DownloadManager.cs
@@ -298,7 +298,12 @@ public sealed class DownloadManager : IDownloadManager
// 4. Lance les workers en parallèle.
// Compteur agrégé via Interlocked → pas de lock dans le hot path.
- long aggregateBytes = state.DownloadedBytes;
+ // Initialisé sur la somme des seg.DownloadedBytes DURABLES plutôt que sur
+ // state.DownloadedBytes (live aggregate persisté, qui peut être en avance
+ // de la réalité disque si pause survenue entre checkpoints). Sans ça, le
+ // footer afficherait "paused at X" mais segments resumeraient à un point
+ // en arrière, et l'aggregate finirait avec un drift cosmétique.
+ long aggregateBytes = state.Segments.Sum(s => s.DownloadedBytes);
// Action passée aux segments : juste un Add atomique (zéro contention).
void OnSegmentBytes(int _, long deltaBytes)
@@ -474,15 +479,46 @@ public sealed class DownloadManager : IDownloadManager
await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true);
dst.Seek(segStart, SeekOrigin.Begin);
+ // DURABILITÉ : on n'incrémente seg.DownloadedBytes qu'APRÈS un FlushAsync
+ // réussi. Sans ça, sur pause/cancel, le buffer FileStream (4 MiB) ou le
+ // cache OS peuvent ne pas avoir atteint le disque, mais seg.DownloadedBytes
+ // les compte comme écrits → resume saute ces bytes → trou (zéros NTFS du
+ // pre-alloc sparse) → SHA-256 fail. Cas observé chez l'utilisateur :
+ // ~80% des reprises post-pause failaient la vérif hash.
+ //
+ // Stratégie : tracker en local le delta non-flushé. Tous les ~64 MiB,
+ // FlushAsync (forcé avec CancellationToken.None pour qu'une pause ne le
+ // coupe pas en plein milieu) puis update seg.DownloadedBytes. Le compteur
+ // est donc TOUJOURS en arrière (ou égal) de ce qui est sur disque, jamais
+ // en avance. Resume re-télécharge au pire 64 MiB par segment — coût
+ // borné, et c'est ce qu'on appelle un checkpoint.
+ const long FlushIntervalBytes = 64L * 1024 * 1024;
var buffer = new byte[BufferSize];
+ long inFlight = 0;
int n;
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
{
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
- seg.DownloadedBytes += n;
+ inFlight += n;
+ // Live counter pour UI (aggregate / footer) — peut overshoot par
+ // rapport au durable de quelques MiB ; le footer revient cohérent
+ // au prochain checkpoint.
onBytes(seg.Index, n);
+
+ if (inFlight >= FlushIntervalBytes)
+ {
+ await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false);
+ seg.DownloadedBytes += inFlight;
+ inFlight = 0;
+ }
+ }
+ // Checkpoint final : flush + ack du reste du buffer.
+ await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false);
+ if (inFlight > 0)
+ {
+ seg.DownloadedBytes += inFlight;
+ inFlight = 0;
}
- await dst.FlushAsync(ct).ConfigureAwait(false);
seg.Completed = (seg.DownloadedBytes >= seg.Length);
// Si on est sorti de la boucle sans avoir atteint la fin du segment, le serveur a