LifePlay-Revival/Docs/Migration/etat/REL/FIX1/FIX1-F6-SPEC.md
2026-10-21 09:36:04 +02:00

36 KiB

Fiche FIX1-F6-SPEC — Niveau N3 — Modèle prévu opus (effort medium) | utilisé claude-opus-5-5 — Agent lp-cart — Skills sk-protocol, ue-cpp-build, lp-commandlets, lp-paths

FIX1-F6-SPEC : fichiers de partie (Saves/…) redirigés vers le dossier de sauvegarde, voie C++ (2026-10-20)

Ordre de l'utilisateur, cité par la fiche : « F6 en build 1 avec la porte J4 : lance la SPEC (opus, effort medium) sur la voie C++. » Base : FIX1-F6-PRE.md (§ 3, 5, 6), ERR-042 (Docs/Registre_Erreurs.md:49), lot REL-SAV (etat/REL/SAV/). Lecture seule : ni code, ni copie UE, ni MCP ; seul ce fichier est écrit. Écart de skills : la fiche cite sk-protocol, lp-lot, lp-paths. lp-lot n'est pas chargé dans mon contexte ; ue-cpp-build et lp-commandlets le sont. Niveaux de preuve : établi (lu à la source, fichier:ligne ou commande), estimé (déduit, raisonnement donné), inconnu (essai qui trancherait nommé).

Résumé (à lire en premier)

  • Un seul fichier change : Plugins/ModdingExtra/Source/ModdingExtra/Private/ModdingExtraBPLibrary.cpp (MD5 avant 7628837e7b77c68c3921acfad3d92ec0, LF). Il gagne un espace de noms LPSavesRedirect d'environ 90 lignes et 2 lignes modifiées (:190, :236). Aucun Blueprint, aucun Build.cs, aucun .uplugin ne change, GI non plus.
  • Règle : un chemin relatif à Content qui commence par Saves/ (sans casse, \ accepté) est écrit sous FPaths::ProjectSavedDir() + "Saves/…". Il est lu au même endroit, sauf pour un slot absent de la nouvelle racine et présent dans l'ancien Content/Saves : le slot entier est alors lu dans l'ancien dossier (repli par slot). _session n'a jamais de repli. Tout autre chemin reste identique, octet pour octet.
  • Racine obtenue : Shipping %LOCALAPPDATA%\LifePlay-Revival\Saved\Saves\… ; Development sans LPSaveLocation.ini <build>\Windows\LifePlay\Saved\Saves\… ; éditeur <copie>\Saved\Saves\….
  • Retour arrière à chaud : l'argument -LPNoSavesRedirect rétablit l'ancien chemin. À froid : Tools\Restore.ps1 depuis l'instantané, ou git revert du commit (un seul fichier).
  • Témoin : LogModdingExtra et non LogLifePlay. LogLifePlay vit dans le module de jeu (Source/LifePlay/LifePlay.h:9), que le plugin ne peut pas voir (Q4).

1. Objectif mesurable

O1. Écriture. Après une nouvelle partie, un enregistrement, un rechargement et une création de personnage, les fichiers *.lpcharacter de la partie se trouvent sous <ProjectSavedDir>/Saves/_session/Characters/ et <ProjectSavedDir>/Saves/<Slot>/Characters/. Rien n'est créé ni modifié sous <jeu>/Content/Saves, contrôlé par un relevé MD5 avant et après (§ 7, E1-E4).

O2. Lecture et repli. Une partie dont le slot n'existe que dans l'ancien Content/Saves/<Slot> se charge sans le message « missing crucial *.lpcharacter files ». Au premier enregistrement, ce slot est écrit dans la nouvelle racine (E5).

O3. Rien d'autre ne bouge. Les chemins hors Saves/ (dont Modules/…) gardent exactement l'expression FPaths::ProjectContentDir() + <chemin>. Le cache LPDataCache sert le même nombre de fichiers (E8).

O4. Désactivation. Avec -LPNoSavesRedirect, le comportement d'avant F6 revient : écriture dans Content/Saves (E6).

1.1 Règle de redirection (demande n° 1)

Chemin relatif reçu (exemples des Blueprints) Lecture (LoadTxt) Écriture (SaveTxt) Source
Saves/_session/Characters/<N>.lpcharacter, …/F<N>, …/C<N>, …/OldPlayer, …/Player <SavedDir>/Saves/_session/…, sans repli <SavedDir>/Saves/_session/… littéraux GI : FIX1-F6-PRE.md § 3.2 (41 + 10 + 4 + 4 correspondances rg -a)
Saves/<Slot>/Characters/… (DuplicateCharacters, Load vrai ou faux) <SavedDir>/Saves/<Slot>/… si le dossier <SavedDir>/Saves/<Slot> existe ; sinon <Content>/Saves/<Slot>/… si <Content>/Saves/<Slot> existe ; sinon <SavedDir>/… (l'échec de lecture y est alors journalisé) <SavedDir>/Saves/<Slot>/… CAM-B1-PRE_LPDumpFunction_20261013-083048.txt:8397-8534 (cité par PRE § 3.1)
Saves/<fichier> posé directement dans Saves/ (slot vide) <SavedDir>/Saves/<fichier>, sans repli idem aucun usage connu (estimé : pas de littéral de cette forme dans le relevé PRE § 3.2)
Saves/Default/… traité comme un slot nommé Default (repli par slot) idem établi : aucun asset ne contient Saves/Default (commande n° 9 : rg -a -l -e "Saves/Default" … Content, code 1, aucun fichier). Seul le guide Docs/Modding/FullGuides/Creating_Presets.txt:13 le cite, comme reste d'une version ancienne (estimé).
tout autre chemin (Modules/…, ModLauncher.config…) FPaths::ProjectContentDir() + chemin, inchangé inchangé ModdingExtraBPLibrary.cpp:190, 236
  • <SavedDir> = FPaths::ProjectSavedDir(), qui se termine par / (Paths.cpp:107, moteur 5.8). Il suit -UserDir, donc le dossier choisi par REL-SAV (REL-SAV-DIAG.md:19-20, Paths.cpp:451-471).
  • <Content> = FPaths::ProjectContentDir(), qui se termine par Content/ (Paths.cpp:477).
  • Le chemin redirigé garde le préfixe Saves/. La racine est donc <SavedDir>/Saves/, à côté de SaveGames/ et sans s'y mêler (Q1).
  • Comparaison sans casse (FString::StartsWith, IgnoreCase par défaut : UnrealString.h.inl:1505). Le nom de slot _session est comparé sans casse (Equals, UnrealString.h.inl:1271).

1.2 Nom et place de la fonction utilitaire

  • Nom : LPSavesRedirect::ResolvePath(const FString& ContentRelativePath, bool bForWrite), qui renvoie le FString à passer à FFileHelper. Elle s'appuie sur trois aides du même espace de noms : Match, Note, IsDisabled. Les compteurs NewReads, OldReads et Writes sont static int32.
  • Place : dans ModdingExtraBPLibrary.cpp, après DEFINE_LOG_CATEGORY_STATIC(LogModdingExtra, …) (:27) et avant le constructeur (:29). Ce n'est pas le module LifePlay, et la raison est établie :
    • LoadTxt et SaveTxt sont dans le plugin ModdingExtra (ModdingExtraBPLibrary.h:37-41) ;
    • le plugin ne dépend que de Core, CoreUObject, AssetRegistry, Json, Engine, Slate et SlateCore (ModdingExtra.Build.cs:22-44) ;
    • LifePlay.Build.cs ne cite pas ModdingExtra (Grep ModdingExtra|LogLifePlay sur Source/LifePlay, filtré sur *.Build.cs et deux autres fichiers : aucune ligne de LifePlay.Build.cs) ;
    • un plugin chargé en PreLoadingScreen (ModdingExtra.uplugin:20) ne peut pas dépendre du module de jeu chargé en Default (LifePlay.uproject:10).
  • Pas d'en-tête public : tout reste static dans le .cpp. L'espace de noms LPSavesRedirect n'existe nulle part ailleurs dans le plugin (Grep LPSavesRedirect|ResolvePath sur Plugins/ModdingExtra/Source : 0). Aucun conflit n'est donc à craindre en build unity (ue-cpp-build § 4).

1.3 Fonctions de fichier de ModdingExtra : couverture (demande n° 2)

Fonction Lignes Base actuelle Décision F6 Raison
LoadTxt 181-232 ; disque :190 ProjectContentDir() + FileNameA redirigée : :190 lit LPSavesRedirect::ResolvePath(FileNameA, false) point de lecture unique des personnages
LoadTxt, relecture de vérification :192-204, disque :195 ProjectContentDir() + FileNameA inchangée elle ne s'exécute que si bFromCache, c'est-à-dire pour modules/… seulement (LPDataCache.cpp:256)
SaveTxt 234-250 ; disque :236 ProjectContentDir() + FileNameB redirigée : :236 écrit vers ResolvePath(FileNameB, true) point d'écriture unique
SaveTxt → OnWritten :239 chemin relatif inchangée : on passe toujours FileNameB relatif voir § 1.4
GetAllFilesFromFolder 40-111 (base :50-51) ProjectContentDir() absolu non redirigée ; ajout d'un avertissement unique si la racine commence par Saves (WarnListOnSaves) aucun appel sur Saves (voir ci-dessous) ; elle renvoie des chemins absolus que les Blueprints recoupent (RightS) : changer la base casserait ce découpage
GetAllSubfolders 114-127 (base :120-121) ProjectContentDir() absolu non redirigée ; même avertissement aucune référence dans Content (voir ci-dessous)
ContentDirectory 252-257 ProjectContentDir() absolu inchangée aucun appel du plugin dans Content (voir ci-dessous)
ModdingExtraSampleFunction, IsPointInsidePolygon 35-38, 259-277 — hors sujet aucun accès fichier

Preuves (établi) :

  • GetAllFilesFromFolder dans GI : 18 nœuds (L6f_dump_GI.txt:17236 … 69567). Leurs racines sont 'Modules', 'Modules/', 'Modules/vin_Base/Presets/{phenotypes,age_groups,female,male}', un SelectString entre deux chemins Modules/… (:17456-17461), ou un paramètre PathFromContent (:47240-47243, fonction d'images). Dans l'EventGraph 5.8, les racines sont 'Modules' ou une concaténation commune, K2Node_CommutativeAssociativeBinaryOperator_664 (CAM-B1-PRE…083048.txt:1538, 1730, 1782, 1810, 10120, 10154, 21729, 24642), pour des extensions de modules (lpstat, lpaction, lpscene, lptalk, lpdesc, lpquest, lpai).
  • GetAllSubfolders et ContentDirectory : rg -a -l -e ContentDirectory -e GetAllSubfolders sur Content trouve 4 fichiers. Le comptage par fichier montre que chaque occurrence de ContentDirectory est en fait GetProjectContentDirectory, une fonction du moteur : GI 2/2, UI/ExtImg 2/2, UI/MainMenu 1/1, UI/FaceTexture 1/1. GetAllSubfolders : 0 occurrence (commandes n° 5 et 6).

1.4 Interaction avec LPDataCache (demande n° 3)

  • TryGet refuse toute clé qui ne commence pas par modules/ (LPDataCache.cpp:255-259). La clé est le chemin relatif mis en minuscules, avec des / (:71-78). LoadTxt lui passe toujours FileNameA relatif, avant toute redirection (ModdingExtraBPLibrary.cpp:189) : rien ne change.
  • OnWritten sort aussitôt pour une clé hors modules/ (LPDataCache.cpp:271-275). Ses relectures ProjectContentDir() + chemin (:284, 289) ne concernent donc que Modules. SaveTxt continue de lui passer FileNameB relatif.
  • Le chargement du cache parcourt Content/Modules seulement (LPDataCache.cpp:103-110) et écrit dans <SavedDir>/LPDataCache (:105). Le dossier <SavedDir>/Saves n'est pas parcouru.
  • Conclusion (établi) : aucun effet sur Modules, ni sur LPDataCache.cpp et LPDataCache.h, qui restent inchangés. Le court-circuit bFromCache || … (:190) garantit que ResolvePath n'est appelée que pour une lecture sur le disque.

1.5 Ce qui ne change pas (demande n° 4)

Élément Statut Preuve
GI et tous les Blueprints aucune écriture. Les littéraux Saves/… restent relatifs et c'est le C++ qui les place FIX1-F6-PRE.md § 3.3
GI.SavePreset (StartsWith(File, "Saves"), sinon Modules/) inchangé : un preset Saves… suit la redirection, un preset Modules/… non L6f_dump_GI.txt:2325-2336
Content/Modules, mods, format .lpcharacter, LPDataCache inchangés § 1.4 ; presets de mods dans Modules/<mod>/Presets/… (L6f_dump_GI.txt:17345-17372, 30858-30872)
SaveGames/*.sav, Config/*.ini, LPSaveRelaunch inchangés REL-SAV-SPEC.md
Message de GI « …copy your old LifePlay\Content\Saves folder… » non modifié en build 1. Il reste vrai à moitié : grâce au repli, un ancien Content/Saves/<Slot> recopié dans le nouveau dossier du jeu est encore lu CAM-B1-PRE…083048.txt:14822-14823 (PRE § 3.2)

Fonctions Blueprint qui construisent un chemin absolu à partir du dossier Content (elles échapperaient à toute redirection) :

  • KismetSystemLibrary::GetProjectContentDirectory, fonction du moteur et non ModdingExtra::ContentDirectory : GI, 1 nœud (L6f_dump_GI.txt:47227, concaténé à un paramètre Path, puis mis dans la table LoadedImages, :47196-47226), ainsi que UI/ExtImg, UI/MainMenu et UI/FaceTexture (commande n° 6).
  • Aucune ne vise Saves : la liste rg -a -o 'Saves[A-Za-z_/]*' de ExtImg, MainMenu et FaceTexture ne donne aucune ligne Saves (commande n° 5, résultat archivé par l'outil, lignes == UI/ExtImg à == UI/FaceTexture). Dans GI, le chemin vient d'un paramètre (images de modules, estimé). Inconnu : la valeur réelle de ce paramètre à l'exécution. L'essai E7 le tranche : aucun accès Saves hors LoadTxt/SaveTxt ne doit apparaître (un Process Monitor filtré sur Content\Saves pendant E1-E4 serait la preuve forte).
  • GetAllFilesFromFolder renvoie des chemins absolus Content/… (ModdingExtraBPLibrary.cpp:62-66, 101). Elle n'est appelée sur Saves nulle part (§ 1.3). L'avertissement WarnListOnSaves signalera tout appel futur, par exemple depuis un mod.

2. Fichiers touchés, ordre

  1. Instantané : .\Tools\Snapshot.ps1 -Lot FIX1 -Path Plugins\ModdingExtra\Source\ModdingExtra\Private\ModdingExtraBPLibrary.cpp. Le MD5 attendu avant est 7628837e7b77c68c3921acfad3d92ec0 (commande n° 8). S'il diffère, arrêt : la SPEC porte sur ce fichier-là.
  2. IMPL (outil Edit, le fichier est en LF, i/lf w/lf) : appliquer le diff indicatif du § 2.1, dans l'ordre des blocs a → g. Aucun autre fichier n'est modifié.
  3. BUILD (lp-builder, éditeur fermé) : Tools\Build.ps1 sur la cible éditeur, puis sur la cible de jeu Development (empaquetage commun de la porte J4).
  4. VAL statique (lp-validator distinct), puis essai en jeu sous le jeton de la copie UE (§ 7).
  5. DOC (lp-writer) : README (:23-24, 74, 82-83, et :117, encore sur l'ancien dossier), ERR-042, guides de modding si Q2 = oui, lp-validate, REF1 (« Content/Saves doit exister mais rester vide », REF1-CART-01.csv:41), doc 37 (classement de F6).

2.1 Diff indicatif (texte, rien n'est appliqué)

Numéros de ligne : ceux du fichier actuel (MD5 ci-dessus). Indentation : 4 espaces, comme les fonctions voisines.

a. Includes, après :14 (#include "Misc/Paths.h")

+#include "Misc/CommandLine.h"
+#include "Misc/Parse.h"

Les deux en-têtes existent dans le moteur 5.8 (Runtime/Core/Public/Misc/CommandLine.h, Misc/Parse.h, Glob). LPDataCache.cpp:9, 87 emploie déjà FParse::Param(FCommandLine::Get(), …).

b. Après :27 (DEFINE_LOG_CATEGORY_STATIC(LogModdingExtra, Log, All);), avant le constructeur :29

+
+// Lot FIX1-F6 (Docs/Migration/etat/REL/FIX1/FIX1-F6-SPEC.md, ERR-042) : fichiers de partie hors du dossier du jeu.
+// Un chemin relatif a Content qui commence par "Saves/" est lu et ecrit sous FPaths::ProjectSavedDir() + "Saves/..."
+// (dossier de sauvegarde, -UserDir du lot REL-SAV compris). Lecture : repli sur l'ancien Content/Saves, par slot,
+// tant que le slot n'existe pas dans la nouvelle racine ; jamais pour _session. Ecriture : toujours la nouvelle racine.
+// Tout autre chemin : inchange. -LPNoSavesRedirect retablit l'ancien comportement. Thread de jeu seulement.
+namespace LPSavesRedirect
+{
+    static int32 NewReads = 0, OldReads = 0, Writes = 0;
+
+    static bool IsDisabled()
+    {
+        static const bool bDisabled = FParse::Param(FCommandLine::Get(), TEXT("LPNoSavesRedirect"));
+        return bDisabled;
+    }
+
+    // Vrai si le chemin vise Saves/. OutRel : separateurs '/', sans '/' de tete. OutSlot : segment qui suit "Saves/"
+    // (vide pour un fichier pose directement dans Saves/).
+    static bool Match(const FString& ContentRelativePath, FString& OutRel, FString& OutSlot)
+    {
+        OutRel = ContentRelativePath;
+        OutRel.ReplaceInline(TEXT("\\"), TEXT("/"));
+        while (OutRel.StartsWith(TEXT("/")))
+        {
+            OutRel = OutRel.Mid(1);
+        }
+        if (!OutRel.StartsWith(TEXT("Saves/"), ESearchCase::IgnoreCase))
+        {
+            return false;
+        }
+        const FString AfterSaves = OutRel.Mid(6);
+        int32 Slash = INDEX_NONE;
+        OutSlot = AfterSaves.FindChar(TEXT('/'), Slash) ? AfterSaves.Left(Slash) : FString();
+        return true;
+    }
+
+    // Journal : une ligne au premier acces redirige, puis une par slot et par racine ; compteurs pour le bilan.
+    static void Note(const FString& Slot, bool bOld, bool bWrite)
+    {
+        static bool bFirst = true;
+        if (bFirst)
+        {
+            bFirst = false;
+            UE_LOG(LogModdingExtra, Display, TEXT("Saves redirige : racine '%s' (repli de lecture par slot : '%s')"),
+                *FPaths::ConvertRelativePathToFull(FPaths::ProjectSavedDir() + TEXT("Saves")),
+                *FPaths::ConvertRelativePathToFull(FPaths::ProjectContentDir() + TEXT("Saves")));
+        }
+        if (bWrite) { ++Writes; } else if (bOld) { ++OldReads; } else { ++NewReads; }
+        static TSet<FString> Seen;
+        bool bAlready = false;
+        Seen.Add(FString(bOld ? TEXT("ancien|") : TEXT("nouveau|")) + Slot.ToLower(), &bAlready);
+        if (!bAlready)
+        {
+            UE_LOG(LogModdingExtra, Display, TEXT("Saves slot '%s' : %s"), *Slot,
+                bOld ? TEXT("lu dans l'ancien Content/Saves (repli)") : TEXT("nouvelle racine"));
+        }
+    }
+
+    // Chemin a passer a FFileHelper. Hors Saves/ : ProjectContentDir() + chemin, exactement comme avant F6.
+    static FString ResolvePath(const FString& ContentRelativePath, bool bForWrite)
+    {
+        FString Rel, Slot;
+        if (!Match(ContentRelativePath, Rel, Slot))
+        {
+            return FPaths::ProjectContentDir() + ContentRelativePath;
+        }
+        if (IsDisabled())
+        {
+            static bool bLogged = false;
+            if (!bLogged)
+            {
+                bLogged = true;
+                UE_LOG(LogModdingExtra, Display, TEXT("Saves non redirige (-LPNoSavesRedirect) : ancien chemin Content/Saves"));
+            }
+            return FPaths::ProjectContentDir() + ContentRelativePath;
+        }
+        const FString NewPath = FPaths::ProjectSavedDir() + Rel;
+        if (bForWrite || Slot.IsEmpty() || Slot.Equals(TEXT("_session"), ESearchCase::IgnoreCase))
+        {
+            Note(Slot, false, bForWrite);
+            return NewPath;
+        }
+        IFileManager& FileManager = IFileManager::Get();
+        if (!FileManager.DirectoryExists(*(FPaths::ProjectSavedDir() + TEXT("Saves/") + Slot))
+            && FileManager.DirectoryExists(*(FPaths::ProjectContentDir() + TEXT("Saves/") + Slot)))
+        {
+            Note(Slot, true, false);
+            return FPaths::ProjectContentDir() + Rel;
+        }
+        Note(Slot, false, false);
+        return NewPath;
+    }
+
+    // Les listes (GetAllFilesFromFolder, GetAllSubfolders) ne sont pas redirigees : signaler un usage sur Saves.
+    static void WarnListOnSaves(const TCHAR* Function, const FString& RootFolder)
+    {
+        FString Rel, Slot;
+        if (!Match(RootFolder + TEXT("/"), Rel, Slot))
+        {
+            return;
+        }
+        static TSet<FString> Warned;
+        bool bAlready = false;
+        Warned.Add(FString(Function) + TEXT("|") + Rel.ToLower(), &bAlready);
+        if (!bAlready)
+        {
+            UE_LOG(LogModdingExtra, Warning, TEXT("%s | '%s' : liste lue dans Content, Saves non redirige pour les listes (FIX1-F6)"),
+                Function, *RootFolder);
+        }
+    }
+}

c. GetAllFilesFromFolder, après :42 (if(RootFolderFullPath.Len() < 1) return false;)

+     LPSavesRedirect::WarnListOnSaves(TEXT("GetAllFilesFromFolder"), RootFolderFullPath);

d. GetAllSubfolders, après :116

+     LPSavesRedirect::WarnListOnSaves(TEXT("GetAllSubfolders"), RootFolderFullPath);

e. LPLoadTxtStats::Dump, après :153 (ligne TOTAL)

+        UE_LOG(LogModdingExtra, Display, TEXT("  Saves : %d lecture(s) nouvelle racine | %d lecture(s) repli | %d ecriture(s)%s"),
+            LPSavesRedirect::NewReads, LPSavesRedirect::OldReads, LPSavesRedirect::Writes,
+            LPSavesRedirect::IsDisabled() ? TEXT(" (redirection desactivee)") : TEXT(""));

f. LoadTxt, :190

-     const bool bOk = bFromCache || FFileHelper::LoadFileToString(SaveTextA, *(FPaths::ProjectContentDir() + FileNameA));
+     const bool bOk = bFromCache || FFileHelper::LoadFileToString(SaveTextA, *LPSavesRedirect::ResolvePath(FileNameA, false));

:195 reste inchangée (§ 1.3).

g. SaveTxt, :236

-     const bool bOk = FFileHelper::SaveStringToFile(SaveTextB, *(FPaths::ProjectContentDir() + FileNameB));
+     const bool bOk = FFileHelper::SaveStringToFile(SaveTextB, *LPSavesRedirect::ResolvePath(FileNameB, true));

:239 (OnWritten(FileNameB, SaveTextB)) reste inchangée.

Bilan du diff : 2 lignes modifiées et ≈ 100 lignes ajoutées dans 1 fichier, 0 supprimée. Les messages de journal sont en ASCII sans accents, comme le reste du fichier.

3. Repli

Repli de lecture (fonctionnel) : par slot, et non par fichier :

  • Un slot lu dans l'ancien dossier l'est en entier. Dès que <SavedDir>/Saves/<Slot> existe (premier enregistrement dans ce slot), plus rien n'est lu dans l'ancien dossier pour ce slot. Une partie ne peut donc pas mêler les personnages de deux parties (risque R2 de PRE).
  • _session n'a jamais de repli. L'ancien Content/Saves/_session contient la copie de travail d'une partie passée, jamais effacée (ModdingExtra n'a aucune fonction de suppression, § 1.3). La relire mêlerait une partie ancienne à la partie en cours.

Migration (demande n° 5, décision R0, REL-SAV-TACHES.md:19) :

  • Aucune copie, aucun déplacement, aucune suppression.
  • Content/Saves/<Slot>/… existants sont lus par le repli jusqu'au premier enregistrement du slot, puis ne sont plus lus. Ils ne sont jamais modifiés.
  • Content/Saves/_session/… déjà présent n'est plus jamais lu ni écrit. Il reste sur le disque, inerte, et le joueur peut l'effacer.
  • Le paquet garde son dossier Content/Saves vide (REF1-CART-01.csv:41). Une nouvelle installation n'y trouve rien, le repli n'y trouve rien non plus, et tout part dans la nouvelle racine.
  • Portée réelle du repli (estimé) : avec R0, les .sav lus sont ceux de LifePlay-Revival\Saved\SaveGames, donc ceux de l'alpha 2026.10.2.1a. Leurs personnages sont dans <dossier de l'alpha>\Content\Saves. Le repli les retrouve si le joueur met à jour sur place, ou s'il a recopié Content\Saves comme le demande le README (README.md:82-83).

Repli technique (retour arrière) :

  • À chaud : lancer avec -LPNoSavesRedirect. Tout Saves/… revient à ProjectContentDir(). Une ligne de journal le signale, et le bilan l'indique.
  • À froid : Tools\Restore.ps1 depuis l'instantané FIX1, ou git revert du commit d'IMPL (1 fichier), puis recompilation.
  • Les fichiers déjà écrits dans <SavedDir>/Saves restent. Si on revient en arrière, le jeu ne les lit plus : les parties enregistrées pendant l'essai sous F6 perdent leurs personnages tant que le dossier n'est pas recopié dans Content/Saves. À écrire dans l'annonce si on revient en arrière après publication.

4. Risques R#, questions Q#

R Risque Parade Niveau
R1 Chemin chaud : LoadTxt sert toutes les données hors Saves/, la branche rend la même expression qu'avant. Seul coût ajouté : une copie de chaîne et un ReplaceInline par lecture sur le disque ; les lectures servies par le cache ne passent pas par ResolvePath (court-circuit :190). VAL : relecture de la branche et bilan LoadTxtStats avant et après estimé : coût négligeable devant LoadFileToString ; à confirmer par le bilan E8
R2 Mélange de deux parties repli par slot, jamais pour _session établi par construction (§ 2.1 b)
R3 Slot à moitié écrit dans la nouvelle racine (enregistrement interrompu), donc plus de repli et des fichiers manquants même conséquence qu'aujourd'hui pour un enregistrement interrompu : le message de GI s'affiche estimé
R4 DirectoryExists à chaque lecture d'un slot (deux appels à stat) pendant DuplicateCharacters quelques centaines de lectures au chargement, estimé ; aucun cache par slot, pour ne pas devenir faux après une écriture estimé ; mesurable par la durée du chargement E3
R5 Journal absent en Shipping bUseLoggingInShipping absent des *.Target.cs (Grep sur Source, 0 ligne), donc journal compilé hors du Shipping (estimé : valeur par défaut du moteur). La preuve en Shipping se fait par les fichiers (E9) établi (absence) ; estimé (effet)
R6 Outils et règles qui supposent Content/Saves (REF1, 21_Sandbox_Addon.md:213, copie d'une partie de test) DOC du lot établi (PRE § 3.3)
R7 Chemin piégé (Saves/../Modules/x) non normalisé : il serait redirigé sous <SavedDir>. Aucun littéral de cette forme (PRE § 3.2). Pas de parade en build 1 estimé
R8 Mods qui lisent ou écrivent Saves/… par LoadTxt/SaveTxt ils suivent la même règle : comportement cohérent estimé
R9 Écriture refusée dans <SavedDir> (droits) SaveTxt journalise déjà l'échec (:243) ; aucun repli d'écriture vers Content (décision U3 de REL-SAV : aucun repli) établi (U3, REL-SAV-TACHES.md:16)
R10 Le moteur crée-t-il les sous-dossiers du slot ? oui : CreateFileWriterInternal appelle MakeDirectory(GetPath(Filename), true) quand l'ouverture échoue (FileManagerGeneric.cpp:110-116, 5.8) établi

Questions fermées : rubrique 8 ci-dessous (Q1-Q7).

5. Interdits

  • Modifier un Blueprint (GI compris), LPDataCache.*, ModdingExtra.Build.cs, .uplugin, .uproject, Source/LifePlay/*.
  • Changer la ligne :195, l'appel OnWritten(FileNameB, …) ou la clé passée à TryGet et LPLoadTxtStats::Record.
  • Rediriger GetAllFilesFromFolder, GetAllSubfolders ou ContentDirectory (avertissement seulement).
  • Copier, déplacer ou supprimer un fichier de Content/Saves (R0).
  • Ajouter un cache par slot, un repli par fichier, ou un repli de lecture pour _session.
  • Compiler dans l'IMPL si la fiche confie la compilation à BUILD. Compiler avec l'éditeur ouvert (ue-cpp-build § 8).
  • Écrire hors de l'instantané et du fichier cible, ou commiter (gardien du dépôt seulement).

6. Valeurs moteur lues dans le moteur installé (D:\Game\Launcher\Engine\UE_5.8\Engine\Source\Runtime\Core)

Valeur Source
FPaths::ProjectSavedDir() : const FString& Public/Misc/Paths.h:290
ProjectSavedDir se termine par / (BaseDir + "Saved" + suffixe facultatif + /) Private/Misc/Paths.cpp:87, 107
ProjectContentDir() = ProjectDir() + "Content/" Paths.h:276 ; Paths.cpp:477
ProjectUserDir suit -UserDir Paths.cpp:451-471 (lu par REL-SAV-DIAG, non relu ici)
IFileManager::DirectoryExists(const TCHAR*) Public/HAL/FileManager.h:119
Écriture : création de l'arborescence quand l'ouverture échoue Private/HAL/FileManagerGeneric.cpp:110-116
FString::StartsWith(const TCHAR*, SearchCase = IgnoreCase) Public/Containers/UnrealString.h.inl:1505
FString::FindChar(ElementType, int32&) UnrealString.h.inl:1219
FString::Equals(const FString&, SearchCase = CaseSensitive) UnrealString.h.inl:1271
FPaths::ConvertRelativePathToFull(const FString&) Paths.h:637
En-têtes Misc/CommandLine.h, Misc/Parse.h Glob dans Public/Misc

7. Critères de VAL (commande → attendu)

7.1 VAL statique (lp-validator)

# Contrôle Attendu
V1 git diff --stat -- Plugins/ModdingExtra 1 fichier, ≈ 100 insertions, 2 suppressions
V2 relecture de la branche !Match de ResolvePath return FPaths::ProjectContentDir() + ContentRelativePath;, identique à l'ancienne expression
V3 :195, :239, TryGet(FileNameA, …), Record(FileNameA, …) inchangés
V4 _session sans repli ; écriture jamais vers ProjectContentDir() quand la redirection est active lu dans le code
V5 git ls-files --eol du fichier i/lf w/lf
V6 Tools\Build.ps1 (BUILD) code 0 ; 0 erreur et 0 avertissement nouveaux dans ModdingExtraBPLibrary.cpp

7.2 Essai en jeu (jeton de la copie UE ; build Development du commun, puis Shipping sur accord)

Préparation, écrite avant de lancer (TRACE) : relevé MD5 de <jeu>\Content\Saves (dossier absent ou vide = cas normal), et de <SavedDir>\Saves (absent attendu), par Get-ChildItem -Recurse -File <dossier> | Get-FileHash -Algorithm MD5.

Cas Montage Attendu
E1 nouvelle partie (Development, Saved à côté de l'exe ou -UserDir d'essai) <SavedDir>\Saves\_session\Characters\*.lpcharacter créés ; journal : LogModdingExtra: Display: Saves redirige : racine '…\Saved\Saves' une fois, et Saves slot '_session' : nouvelle racine
E2 enregistrement dans un slot <SavedDir>\Saves\<Slot>\Characters\ contient les familles N, F, C, OldPlayer, Player présentes dans _session
E3 Quitter, puis Continuer ou Charger partie identique, sans le message « missing crucial *.lpcharacter files » ; Saves slot '<Slot>' : nouvelle racine
E4 création de personnage (CharGen, BodyShapeMenu) fichiers écrits sous <SavedDir>\Saves\…
E1-E4 relevé de <jeu>\Content\Saves après l'essai identique au relevé d'avant (0 fichier créé ni modifié)
E5 repli : copier un slot d'essai (copie d'un slot de l'alpha, jamais l'original) dans <jeu>\Content\Saves\<SlotX>, avec son .sav dans <SavedDir>\SaveGames chargement sans message ; journal Saves slot '<SlotX>' : lu dans l'ancien Content/Saves (repli) ; après enregistrement dans <SlotX>, <SavedDir>\Saves\<SlotX> existe et un nouveau chargement donne nouvelle racine ; relevé MD5 de Content\Saves\<SlotX> inchangé
E6 -LPNoSavesRedirect, nouvelle partie Saves non redirige (-LPNoSavesRedirect) ; fichiers dans <jeu>\Content\Saves\_session\… (ancien comportement) ; bilan (redirection desactivee)
E7 Select-String -Path <journal> -Pattern 'FIX1-F6' 0 ligne : aucune liste n'a visé Saves
E8 ModdingExtra.LoadTxtStats à la console et à la fermeture ligne Saves : n lecture(s) nouvelle racine | m repli | k ecriture(s), avec n > 0 et k > 0 après E1-E4 ; ligne Cache de donnees pret : <x> fichier(s) avec le même x qu'avant F6 (journal de référence du même build sans F6) ; aucune LoadTxt CACHE ECART avec -LPDataCacheVerify
E9 (Shipping, sur accord) Shipping sans .ini %LOCALAPPDATA%\LifePlay-Revival\Saved\Saves\… créé ; relevé de <jeu>\Content\Saves inchangé (sans journal en Shipping : R5)

Lecture des journaux : Select-String -Path <journal> -Pattern 'LogModdingExtra: (Display|Warning): Saves','FIX1-F6','LoadTxt ECHEC'. Toute ligne LoadTxt ECHEC sur un chemin Saves/ pendant E1-E5 est un écart à rapporter.

Porte J4 (proposition) : PASS si V1-V6 sont conformes et si E1-E8 le sont au J4 soir. Sinon, retour arrière à froid (§ 3), F6 passe en build 2, et le README garde la limite connue.

Coût (estimé, non mesuré) :

  • IMPL lp-impl (Sonnet) : 8 à 15 tours, sans la copie UE ;
  • BUILD : une compilation incrémentale du plugin et du jeu ;
  • VAL statique : 8 à 12 tours ;
  • essai en jeu : 45 à 60 minutes sous le jeton (E5 demande de préparer une copie de slot) ;
  • DOC : 10 à 15 tours.

8. Questions fermées pour l'utilisateur (avec recommandation)

Q Question Choix Recommandation
Q1 Nom de la racine (a) <SavedDir>\Saves\… ; (b) <SavedDir>\SaveGames\Saves\… ; (c) <SavedDir>\Characters\… (a) : le chemin relatif Saves/… est gardé tel quel (pas de réécriture du chemin, donc moins de risque). Les deux dossiers restent distincts : SaveGames (moteur, .sav) et Saves (personnages). Le joueur sauvegarde tout le dossier Saved d'un coup.
Q2 Corriger les guides de modding (Docs/Modding/ShortGuides/explanation.lpcharacter:3, Docs/Modding/FullGuides/Creating_Presets.txt:7, 13, 15) oui / non Oui, en DOC du lot, seulement si la porte J4 passe. Ces lignes décrivent l'emplacement des fichiers que le jeu génère, pas le contrat des mods : format et Modules/<mod>/Presets sont inchangés. Retirer Saves/Default (absent de tous les assets, commande n° 9).
Q3 Porte J4 critères du § 7.2 / autre Garder les critères du § 7.2 : V1-V6 et E1-E8 au J4 soir, sinon build 2. E9 (Shipping) sur accord.
Q4 Catégorie du témoin LogModdingExtra / LogLifePlay LogModdingExtra. LogLifePlay n'est pas visible depuis le plugin (§ 1.2). Redéclarer une catégorie statique du même nom dans le plugin serait trompeur, puisque log LogLifePlay … ne réglerait qu'une des deux.
Q5 Nom de l'interrupteur -LPNoSavesRedirect / autre -LPNoSavesRedirect, sur le modèle de -LPNoDataCache (LPDataCache.cpp:87) et -LPNoSaveRelaunch (REL-SAV-SPEC.md:39).
Q6 Durée de vie du repli de lecture jusqu'à la build 2 / permanent Permanent en build 1, réexaminé en build 2 (coût : deux tests de dossier par lecture d'un slot).
Q7 Message de GI « copy your old LifePlay\Content\Saves folder… » corriger en build 1 / en build 2 Build 2, avec la révision unique de GI. Le message reste juste à moitié grâce au repli (§ 1.5).

Constats

  • Écart de méthode (établi) : rg -a sur des .uasset compte des correspondances dans le binaire, pas des nœuds. Le comptage ContentDirectory contre GetProjectContentDirectory, fichier par fichier (commande n° 6), est ce qui prouve que la fonction du plugin n'est pas appelée. Un Grep sur le seul mot ContentDirectory donnait 4 fichiers « positifs » à tort.
  • Inconnu restant : les relevés de GI datent du dump 5.5 (L6f, 2026-09-30) et de l'EventGraph 5.8 (CAM-B1-PRE, 2026-10-13). Les fonctions de GI n'ont pas été relevées de nouveau en 5.8. La redirection ne dépend pas de ce relevé, puisqu'elle est faite en C++ quel que soit l'appelant. E7 et le relevé MD5 de Content\Saves couvrent le cas d'un accès oublié.

Commandes et lectures de la fiche (comptes bruts) :

  1. Read FIX1-F6-PRE.md, REL-SAV-SPEC.md, REL-SAV-DIAG.md, REL-SAV-TACHES.md, ModdingExtraBPLibrary.cpp (279 l.), .h, ModdingExtra.Build.cs, .uplugin, LPDataCache.h, LPDataCache.cpp:60-99, 240-293, LPSaveRelaunch.h.
  2. Grep appel ModdingExtraBPLibrary::(GetAllFilesFromFolder|GetAllSubfolders|ContentDirectory) dans L6f_dump_GI.txt → 18 lignes, toutes GetAllFilesFromFolder.
  3. Grep GetAllFilesFromFolder (-A 8) dans CAM-B1-PRE…083048.txt → 8 nœuds, racines 'Modules' ou concaténation _664.
  4. Grep ContentDirectory|GetAllSubfolders dans L6f_dump_GI.txt → 1 nœud, KismetSystemLibrary::GetProjectContentDirectory() (:47227).
  5. rg -a -l -e ContentDirectory -e GetAllSubfolders -g "*.uasset" -g "*.umap" Content → BPs\GI, UI\ExtImg, UI\MainMenu, UI\FaceTexture, code 0. Puis rg -a -o … | sort | uniq -c sur ces 4 fichiers : Saves… seulement dans GI.
  6. rg -a -o -e GetProjectContentDirectory -e ModdingExtraBPLibrary -e "[A-Za-z]*ContentDir[A-Za-z]*" sur 9 assets → GI 2 / 2, ExtImg 2, MainMenu 1, FaceTexture 1 (GetProjectContentDirectory) ; ModdingExtraBPLibrary 2 dans GI, MainMenu, CharGen, StatMenu, Contact, ContactMenu, BodyShapeMenu.
  7. rg -a -l -e BlueprintFileUtils -e ImportFileAsTexture2D -e DeleteFile -e DirectoryExists -e LoadFileToString … Content → code 1 (aucun fichier).
  8. git log --oneline -3 -- …ModdingExtraBPLibrary.cpp → b8414af, d443257, 4dfb70e ; git status --porcelain -- Plugins/ModdingExtra → vide ; md5sum → 7628837e7b77c68c3921acfad3d92ec0 ; git ls-files --eol → i/lf w/lf attr/-text.
  9. rg -a -l -e "Saves/Default" -e "Saves\\\\" … Content → code 1 ; rg -n -i Saves README.md Docs/Modding → README.md:23-24, 74, 82-83, 117, 259, Creating_Presets.txt:7, 13, 15, explanation.lpcharacter:3.
  10. Grep moteur : FileManagerGeneric.cpp:95-116, Paths.cpp:84-115, 477, Paths.h:276, 290, 637, FileManager.h:119, UnrealString.h.inl:1219, 1271, 1505.
  11. Grep bUseLoggingInShipping dans Source/*.Target.cs → aucune ligne.

Fichiers à commiter (dépôt du jeu, ue5.8) :

  • A Docs/Migration/etat/REL/FIX1/FIX1-F6-SPEC.md

Tours : 20 / 25