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:
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
{
|
||||||
copied++;
|
await src.CopyToAsync(dst, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user