394 lines
36 KiB
Markdown
394 lines
36 KiB
Markdown
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"`)**
|
|
```cpp
|
|
+#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`**
|
|
```cpp
|
|
+
|
|
+// 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;`)**
|
|
```cpp
|
|
+ LPSavesRedirect::WarnListOnSaves(TEXT("GetAllFilesFromFolder"), RootFolderFullPath);
|
|
```
|
|
|
|
**d. `GetAllSubfolders`, après `:116`**
|
|
```cpp
|
|
+ LPSavesRedirect::WarnListOnSaves(TEXT("GetAllSubfolders"), RootFolderFullPath);
|
|
```
|
|
|
|
**e. `LPLoadTxtStats::Dump`, après `:153` (ligne `TOTAL`)**
|
|
```cpp
|
|
+ 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`**
|
|
```cpp
|
|
- 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`**
|
|
```cpp
|
|
- 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
|