v1.0.4 — Conserver les sauvegardes : user-data wins over ZIP defaults

Bug rapporté par l'opérateur : upgrade 1.5.4.30 → 1.5.4.32 sur un poste,
les paramètres customisés dans 1.5.4.30 n'étaient pas visibles au premier
lancement de 1.5.4.32 alors que la case « Conserver les sauvegardes » était
cochée.

Root cause : le ZIP de la nouvelle version bundle des .sav par défaut (typ.
PROSERVE_UE_5_7/Saved/SaveGames/GeneralSettings.sav = réglages usine).
Après extraction, ce fichier existe déjà dans le dossier cible. Ma logique
« non-destructive » précédente skippait alors la copie depuis la version
précédente pour « ne pas écraser un fichier bundlé » — mais c'est
exactement l'inverse qu'on veut : les données utilisateur (progression,
réglages persos, replays) DOIVENT primer sur les defaults du ZIP.

Fix : mode « USER-DATA WINS » — on écrase toujours le fichier cible s'il
existe. La version précédente contient soit la même valeur (no-op), soit
la valeur customisée par l'opérateur (recherchée). Aucune raison légitime
de préserver les defaults bundlés au détriment de user data.

Logging bumpé Debug → Information sur les branches critiques de
CopyPreviousSaveGamesAsync (nombre de versions scannées, path source
résolu, count trouvé par sous-dossier). Facilite le diagnostic de ce genre
de régression à l'avenir sans obliger l'opérateur à activer un mode verbose.

Bump : 1.0.3 → 1.0.4 (bug fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 14:55:10 +02:00
parent 8d8cf32e12
commit ff05edbe7e
3 changed files with 52 additions and 28 deletions

View File

@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher" #define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher" #define MyAppShortName "PS_Launcher"
#define MyAppVersion "1.0.3" #define MyAppVersion "1.0.4"
#define MyAppPublisher "ASTERION VR" #define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com" #define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe" #define MyAppExeName "PS_Launcher.exe"

View File

@@ -18,9 +18,9 @@
<Product>PROSERVE Launcher</Product> <Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright> <Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace> <RootNamespace>PSLauncher.App</RootNamespace>
<Version>1.0.3</Version> <Version>1.0.4</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion> <AssemblyVersion>1.0.4.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion> <FileVersion>1.0.4.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) --> <!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile> <PublishSingleFile>true</PublishSingleFile>

View File

@@ -2381,9 +2381,12 @@ public sealed partial class MainViewModel : ObservableObject
/// (<c>PROSERVE_UE_5_7.exe</c> → <c>PROSERVE_UE_5_7/</c>). Compatible avec /// (<c>PROSERVE_UE_5_7.exe</c> → <c>PROSERVE_UE_5_7/</c>). Compatible avec
/// les passages d'une version UE à l'autre (UE_5_5 → UE_5_7) — on copie /// les passages d'une version UE à l'autre (UE_5_5 → UE_5_7) — on copie
/// du dossier UE source vers le dossier UE cible (renommage transparent).</item> /// du dossier UE source vers le dossier UE cible (renommage transparent).</item>
/// <item>Mode COPIE NON DESTRUCTIVE : si le nouveau ZIP livre déjà un /// <item>Mode USER-DATA WINS : les <c>.sav</c>/<c>.replay</c> sont des
/// fichier au même chemin (ex : profil par défaut bundlé), on NE /// données utilisateur (progression, réglages, replays de session). Si
/// l'écrase PAS. Seuls les fichiers absents côté cible sont copiés.</item> /// le nouveau ZIP livre un fichier au même chemin (ex : profil par
/// défaut bundlé <c>GeneralSettings.sav</c>), on l'ÉCRASE avec la
/// version de l'utilisateur — sinon les réglages custom de la version
/// précédente seraient perdus au profit des defaults du ZIP.</item>
/// <item>Best-effort : exceptions IO loggées en warn mais non remontées — /// <item>Best-effort : exceptions IO loggées en warn mais non remontées —
/// l'install ne doit pas échouer pour une copie de saves.</item> /// l'install ne doit pas échouer pour une copie de saves.</item>
/// </list> /// </list>
@@ -2391,12 +2394,18 @@ public sealed partial class MainViewModel : ObservableObject
private async Task CopyPreviousSaveGamesAsync(string newInstallDir, string newVersion, CancellationToken ct) private async Task CopyPreviousSaveGamesAsync(string newInstallDir, string newVersion, CancellationToken ct)
{ {
var installed = _registry.Scan(); var installed = _registry.Scan();
_logger.LogInformation(
"SaveGames copy for v{New} — scanning {Count} installed version(s): [{Versions}]",
newVersion, installed.Count, string.Join(", ", installed.Select(v => v.Version)));
var previous = installed var previous = installed
.Where(v => !string.Equals(v.Version, newVersion, StringComparison.OrdinalIgnoreCase)) .Where(v => !string.Equals(v.Version, newVersion, StringComparison.OrdinalIgnoreCase))
.FirstOrDefault(); .FirstOrDefault();
if (previous is null) if (previous is null)
{ {
_logger.LogDebug("No previous version installed, nothing to copy for v{Version}", newVersion); _logger.LogInformation(
"SaveGames copy skipped : no previous version found other than v{New} (aucune version antérieure à copier)",
newVersion);
return; return;
} }
@@ -2408,10 +2417,15 @@ public sealed partial class MainViewModel : ObservableObject
var srcProjectName = Path.GetFileNameWithoutExtension(previous.ExecutablePath); var srcProjectName = Path.GetFileNameWithoutExtension(previous.ExecutablePath);
if (string.IsNullOrWhiteSpace(srcProjectName)) if (string.IsNullOrWhiteSpace(srcProjectName))
{ {
_logger.LogDebug("Cannot resolve project name from previous exe {Exe}", previous.ExecutablePath); _logger.LogWarning(
"SaveGames copy aborted : cannot resolve project name from previous exe {Exe}",
previous.ExecutablePath);
return; return;
} }
var srcSavedRoot = Path.Combine(previous.FolderPath, srcProjectName, "Saved"); var srcSavedRoot = Path.Combine(previous.FolderPath, srcProjectName, "Saved");
_logger.LogInformation(
"SaveGames copy source resolved : v{From} at {Root} (project = {Proj})",
previous.Version, srcSavedRoot, srcProjectName);
// Pre-scan : on liste TOUS les fichiers à copier (savegames + demos) AVANT // Pre-scan : on liste TOUS les fichiers à copier (savegames + demos) AVANT
// de toucher quoi que ce soit, pour pouvoir afficher un count total dans // de toucher quoi que ce soit, pour pouvoir afficher un count total dans
@@ -2420,13 +2434,23 @@ public sealed partial class MainViewModel : ObservableObject
foreach (var (subDir, glob) in PreservedSavedSubdirs) foreach (var (subDir, glob) in PreservedSavedSubdirs)
{ {
var srcSubDir = Path.Combine(srcSavedRoot, subDir); var srcSubDir = Path.Combine(srcSavedRoot, subDir);
if (!Directory.Exists(srcSubDir)) continue; if (!Directory.Exists(srcSubDir))
foreach (var file in Directory.GetFiles(srcSubDir, glob, SearchOption.AllDirectories)) {
_logger.LogInformation(
"SaveGames copy : source subdir absent, skipping — {Path}", srcSubDir);
continue;
}
var found = Directory.GetFiles(srcSubDir, glob, SearchOption.AllDirectories);
_logger.LogInformation(
"SaveGames copy : {Count} × {Glob} found in {Path}", found.Length, glob, srcSubDir);
foreach (var file in found)
plan.Add((subDir, file)); plan.Add((subDir, file));
} }
if (plan.Count == 0) if (plan.Count == 0)
{ {
_logger.LogDebug("Previous v{Version} has no save files / demos to copy", previous.Version); _logger.LogInformation(
"SaveGames copy skipped : previous v{Version} has no matching files in {Root}",
previous.Version, srcSavedRoot);
return; return;
} }
@@ -2451,7 +2475,7 @@ public sealed partial class MainViewModel : ObservableObject
"Copying {Count} user file(s) from v{From} ({SrcProj}) to v{To} ({DstProj}) — savegames+demos", "Copying {Count} user file(s) from v{From} ({SrcProj}) to v{To} ({DstProj}) — savegames+demos",
plan.Count, previous.Version, srcProjectName, newVersion, dstProjectName); plan.Count, previous.Version, srcProjectName, newVersion, dstProjectName);
int copied = 0, skippedExisting = 0; int copied = 0, overwritten = 0;
foreach (var (subDir, srcFile) in plan) foreach (var (subDir, srcFile) in plan)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
@@ -2460,20 +2484,20 @@ public sealed partial class MainViewModel : ObservableObject
var dstFile = Path.Combine(dstSavedRoot, subDir, relPath); var dstFile = Path.Combine(dstSavedRoot, subDir, relPath);
try try
{ {
if (File.Exists(dstFile)) // Mode USER-DATA WINS : on ÉCRASE toujours le fichier cible s'il
{ // existe déjà. Le ZIP peut livrer des defaults (ex :
// Le ZIP livre déjà ce fichier (profil/replay bundlé) — on ne // GeneralSettings.sav avec les réglages usine) mais la version
// l'écrase pas. La version la plus à jour côté installer prime // customisée par l'utilisateur dans l'install précédente prime
// pour ce slot précis. Les autres fichiers (créés par l'opérateur // sinon les réglages custom seraient perdus à chaque upgrade au
// en cours d'utilisation) sont copiés normalement. // profit des defaults bundlés dans le ZIP.
skippedExisting++; bool wasOverwrite = File.Exists(dstFile);
continue;
}
Directory.CreateDirectory(Path.GetDirectoryName(dstFile)!); Directory.CreateDirectory(Path.GetDirectoryName(dstFile)!);
await using var src = new FileStream(srcFile, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); await using (var src = new FileStream(srcFile, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true))
await using var dst = new FileStream(dstFile, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true); await using (var dst = new FileStream(dstFile, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true))
{
await src.CopyToAsync(dst, ct).ConfigureAwait(false); await src.CopyToAsync(dst, ct).ConfigureAwait(false);
copied++; }
if (wasOverwrite) overwritten++; else copied++;
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex) catch (Exception ex)
@@ -2483,8 +2507,8 @@ public sealed partial class MainViewModel : ObservableObject
} }
_logger.LogInformation( _logger.LogInformation(
"User data copy complete : {Copied} copied, {Skipped} skipped (already in new install)", "User data copy complete : {Copied} new + {Overwritten} overwritten (ZIP defaults replaced by user data)",
copied, skippedExisting); copied, overwritten);
} }
private async Task UninstallVersionAsync(VersionRowViewModel row) private async Task UninstallVersionAsync(VersionRowViewModel row)