diff --git a/Docs/Migration/11_Audit_Optimisation.md b/Docs/Migration/11_Audit_Optimisation.md index efa7bdd..08e66e3 100644 --- a/Docs/Migration/11_Audit_Optimisation.md +++ b/Docs/Migration/11_Audit_Optimisation.md @@ -530,4 +530,28 @@ Conséquences pour la suite : - **Étapes 2 et 3, réunies** (un fichier compilé lu une fois, puis `LoadTxt` servi depuis la mémoire) : environ −5 à −6 s au premier chargement d'une partie, gain faible ensuite. - **Étape 4** (analyse en C++) ou suppression des relances inutiles de `PopulateRaws` : jusqu'à −6 s **à chaque** - passage. \ No newline at end of file + passage. + +### 17.3 Étapes 2 et 3 : couche de données (2026-10-02) + +Nouveau `Plugins/ModdingExtra/Source/ModdingExtra/Private/LPDataCache.h/.cpp`, lancé au démarrage du module (hors +commandlets) : +- **relevé** : date et taille des fichiers texte de `Modules` (`.lp*`, `.txt`, `.config`, `.cfg`), sans ouverture ; +- **cache compilé** : un JSON par type dans `Saved/LPDataCache/.json` (`version`, `type`, `files`). Pour chaque + fichier : `stamp`, `size`, `text` et, pour `.lpaction`, `.lpstat`, `.lpquest`, `.lpmod`, des `fields` (champs + « CLÉ: valeur » analysés). Le **calque** de valeurs par défaut, fichier optionnel + `Content/Modules/_lpdata_defaults.json`, complète les champs absents des enregistrements ; +- **mise à jour incrémentale** : seuls les fichiers nouveaux ou modifiés sont relus (mods ajoutés, éditions) ; +- **`LoadTxt`** sert le texte depuis la mémoire, identique à la lecture du disque ; **`SaveTxt`** met la mémoire à + jour ; la lecture se fait sur le disque avant la fin du chargement, ou hors `Modules` (sauvegardes) ; +- options : `-LPNoDataCache` pour désactiver, `-LPDataCacheVerify` pour comparer chaque texte servi au disque. + +| Mesure (build `LifePlay_Build_5.5_DATA23`, en arrière-plan au menu) | Valeur | +|---|---:| +| 1ᵉʳ lancement : construction du cache (2 051 fichiers, images comprises) | 12,3 s | +| Lancements suivants : chargement depuis le cache (2 031 fichiers texte) | **0,14 s** | +| Relevé des dates et tailles | 0,014 s | +| Cache (11 fichiers JSON) | ≈ 8 Mo, dont `lpscene` 4,6 Mo | + +Validation en jeu en attente : partie lancée avec `LifePlay_verif_cache.bat` (`-LPDataCacheVerify`), puis bilan +`LoadTxt` et absence d'écart dans le journal. \ No newline at end of file diff --git a/Docs/Migration/Journal_Modifications.md b/Docs/Migration/Journal_Modifications.md index f3597a0..a0712b6 100644 --- a/Docs/Migration/Journal_Modifications.md +++ b/Docs/Migration/Journal_Modifications.md @@ -920,3 +920,4 @@ Validation L7c/L7d (2026-10-02, parc, même cadrage) : la nuit reste claire (sol | CHG-073 | 2026-10-02 | ERR-038 | `Source/LifePlay/LPRoomTransitionSubsystem.h/.cpp` supprimés (copie dans `D:\Game\LifePlay_Backup_5.5\ERR038_retrait`) ; build `LifePlay_Build_5.5_ERR038` reconstruit sans le sous-système (vérifié dans l'exécutable) | second test « plus problématique » : relevé `ERR038_apres2_20261002` = 2 déclenchements, tous deux à tort (clients du lieu d'arrivée cachés 3 s) ; le critère temporel ne distingue pas les personnages créés pendant le trajet puis retirés de ceux qui sont conservés ; retrait proposé et accepté par l'utilisateur ; ERR-038 reste ouvert | `git checkout ERR038-apres -- Source/LifePlay` | | CHG-074 | 2026-10-02 | O7 | O7a : `Content/Saves` (14 125 fichiers, parties de test) → `D:\Game\LifePlay_Backup_5.5\O7\Content_Saves` (vérifié), `Content/Developers` et `Content/Collections` (dossiers vides) retirés, 3 captures de la racine rangées ; O7b : `08_Guide_Developpeur.md` §2.1 (carte de `Content/Heavy`, dossiers à ne pas déplacer) | ménage sans risque et documentation, à la place d'un renommage des dossiers jugé trop risqué (préfixes de chemins en dur, contrat de modding) | déplacer en retour le contenu de `O7\Content_Saves` | | CHG-075 | 2026-10-02 | DATA1 | `Plugins/ModdingExtra/.../ModdingExtraBPLibrary.cpp` : statistiques de `LoadTxt` par type de fichier (bilan à la fermeture, commande `ModdingExtra.LoadTxtStats`) ; `11_Audit_Optimisation.md` §17 (stratégie de couche de données) | étape 1 de la couche de données : mesurer avant de construire ; `PopulateRaws` = 12,6 s par chargement de partie (trace O2) ; build `LifePlay_Build_5.5_DATA1` | `git checkout DATA1-avant -- Plugins/ModdingExtra` | +| CHG-076 | 2026-10-02 | DATA23 | nouveaux `Plugins/ModdingExtra/.../LPDataCache.h/.cpp` ; `ModdingExtra.cpp` (démarrage en arrière-plan), `ModdingExtraBPLibrary.cpp` (`LoadTxt` depuis la mémoire, `SaveTxt`, vérification, statistiques cache/disque), `ModdingExtra.Build.cs` (`Json`) | couche de données étapes 2 et 3 : `Modules` chargé une fois en arrière-plan, cache compilé JSON par type avec champs et calque ; 2 031 fichiers en 0,14 s depuis le cache ; build `LifePlay_Build_5.5_DATA23` ; validation en jeu en attente | `git checkout DATA23-avant -- Plugins/ModdingExtra` ; ou option `-LPNoDataCache` | diff --git a/Plugins/ModdingExtra/Source/ModdingExtra/ModdingExtra.Build.cs b/Plugins/ModdingExtra/Source/ModdingExtra/ModdingExtra.Build.cs index 5625f53..980fb49 100644 --- a/Plugins/ModdingExtra/Source/ModdingExtra/ModdingExtra.Build.cs +++ b/Plugins/ModdingExtra/Source/ModdingExtra/ModdingExtra.Build.cs @@ -34,6 +34,7 @@ public class ModdingExtra : ModuleRules { "CoreUObject", "AssetRegistry", // portage 5.5, lot O2b : enumeration des assets en conteneurs IoStore + "Json", // portage 5.5, couche de donnees : cache compile (Saved/LPDataCache) "Engine", "Slate", "SlateCore", diff --git a/Plugins/ModdingExtra/Source/ModdingExtra/Private/LPDataCache.cpp b/Plugins/ModdingExtra/Source/ModdingExtra/Private/LPDataCache.cpp new file mode 100644 index 0000000..0e2e9c0 --- /dev/null +++ b/Plugins/ModdingExtra/Source/ModdingExtra/Private/LPDataCache.cpp @@ -0,0 +1,277 @@ +// Portage UE 5.5 - voir LPDataCache.h + +#include "LPDataCache.h" + +#include "Async/Async.h" +#include "Dom/JsonObject.h" +#include "HAL/FileManager.h" +#include "HAL/PlatformTime.h" +#include "Misc/CommandLine.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" + +DEFINE_LOG_CATEGORY_STATIC(LogLPDataCache, Log, All); + +namespace +{ + constexpr int32 CacheVersion = 1; + const TCHAR* ModulesFolder = TEXT("Modules"); + + /** Formats "CLE: valeur" dont les champs sont analyses dans le cache (base du calque). */ + bool IsKeyValueFormat(const FString& Ext) + { + return Ext == TEXT("lpaction") || Ext == TEXT("lpstat") || Ext == TEXT("lpquest") || Ext == TEXT("lpmod"); + } + + /** Champs "CLE: valeur" d'un texte (une ligne par champ, premiere occurrence retenue). */ + TSharedRef ParseFields(const FString& Text, const TSharedPtr& Defaults) + { + TSharedRef Fields = MakeShared(); + TArray Lines; + Text.ParseIntoArrayLines(Lines, true); + for (const FString& Line : Lines) + { + int32 Colon = INDEX_NONE; + if (Line.FindChar(TEXT(':'), Colon) && Colon > 0) + { + const FString Key = Line.Left(Colon).TrimStartAndEnd(); + if (!Key.Contains(TEXT(" ")) && !Fields->HasField(Key)) + { + Fields->SetStringField(Key, Line.Mid(Colon + 1).TrimStartAndEnd()); + } + } + } + if (Defaults.IsValid()) + { + for (const auto& Pair : Defaults->Values) + { + if (!Fields->HasField(Pair.Key)) + { + Fields->SetField(Pair.Key, Pair.Value); + } + } + } + return Fields; + } +} + +FLPDataCache& FLPDataCache::Get() +{ + static FLPDataCache Instance; + return Instance; +} + +FString FLPDataCache::MakeKey(const FString& ContentRelativePath) +{ + FString Key = ContentRelativePath; + Key.ReplaceInline(TEXT("\\"), TEXT("/")); + while (Key.ReplaceInline(TEXT("//"), TEXT("/")) > 0) {} + Key.RemoveFromStart(TEXT("/")); + return Key.ToLower(); +} + +void FLPDataCache::StartAsync() +{ + if (bStarted) + { + return; + } + bStarted = true; + bDisabled = FParse::Param(FCommandLine::Get(), TEXT("LPNoDataCache")); + bVerify = FParse::Param(FCommandLine::Get(), TEXT("LPDataCacheVerify")); + if (bDisabled) + { + UE_LOG(LogLPDataCache, Display, TEXT("Cache de donnees desactive (-LPNoDataCache)")); + return; + } + Async(EAsyncExecution::Thread, [this]() { LoadAll(); }); +} + +void FLPDataCache::LoadAll() +{ + const double Start = FPlatformTime::Seconds(); + const FString ContentDir = FPaths::ConvertRelativePathToFull(FPaths::ProjectContentDir()); + const FString ModulesDir = ContentDir / ModulesFolder; + const FString CacheDir = FPaths::ConvertRelativePathToFull(FPaths::ProjectSavedDir()) / TEXT("LPDataCache"); + + // 1. Releve des fichiers de Modules : date et taille, sans ouverture. + struct FFound { FString Key; FString FullPath; FString Ext; FDateTime Stamp; int64 Size; }; + TArray Found; + IFileManager::Get().IterateDirectoryStatRecursively(*ModulesDir, [&](const TCHAR* Path, const FFileStatData& Stat) + { + if (!Stat.bIsDirectory) + { + FString Full(Path); + FString Relative = Full; + FPaths::MakePathRelativeTo(Relative, *(ContentDir + TEXT("/"))); + FString Ext = FPaths::GetExtension(Full).ToLower(); + // Formats texte seulement (les images .jpg des modules sont lues autrement, jamais par LoadTxt). + if (!(Ext.StartsWith(TEXT("lp")) || Ext == TEXT("txt") || Ext == TEXT("config") || Ext == TEXT("cfg"))) + { + return true; + } + Found.Add({ MakeKey(Relative), Full, Ext, Stat.ModificationTime, Stat.FileSize }); + } + return true; + }); + const double ScanSeconds = FPlatformTime::Seconds() - Start; + + // Calque de valeurs par defaut (optionnel). + TSharedPtr AllDefaults; + { + FString DefaultsText; + if (FFileHelper::LoadFileToString(DefaultsText, *(ModulesDir / TEXT("_lpdata_defaults.json")))) + { + FJsonSerializer::Deserialize(TJsonReaderFactory<>::Create(DefaultsText), AllDefaults); + } + } + + // 2. Cache compile existant, par type. + TMap> Cached; + TSet Exts; + for (const FFound& F : Found) { Exts.Add(F.Ext); } + for (const FString& Ext : Exts) + { + FString Json; + TSharedPtr Root; + int32 Version = 0; + const TSharedPtr* FilesPtr = nullptr; + if (FFileHelper::LoadFileToString(Json, *(CacheDir / (Ext + TEXT(".json")))) + && FJsonSerializer::Deserialize(TJsonReaderFactory<>::Create(Json), Root) && Root.IsValid() + && Root->TryGetNumberField(TEXT("version"), Version) && Version == CacheVersion + && Root->TryGetObjectField(TEXT("files"), FilesPtr)) + { + Cached.Add(Ext, *FilesPtr); + } + } + + // 3. Entrees : depuis le cache si date et taille identiques, sinon lecture du disque. + TMap NewEntries; + TSet ChangedExts; + int32 FromCache = 0, FromDisk = 0; + for (const FFound& F : Found) + { + FEntry Entry; + Entry.Stamp = F.Stamp; + Entry.Size = F.Size; + bool bHit = false; + if (const TSharedPtr* Files = Cached.Find(F.Ext)) + { + const TSharedPtr* Obj = nullptr; + FString CachedStamp, CachedText; + double CachedSize = -1.0; + if ((*Files)->TryGetObjectField(F.Key, Obj) + && (*Obj)->TryGetStringField(TEXT("stamp"), CachedStamp) && CachedStamp == F.Stamp.ToIso8601() + && (*Obj)->TryGetNumberField(TEXT("size"), CachedSize) && (int64)CachedSize == F.Size + && (*Obj)->TryGetStringField(TEXT("text"), CachedText)) + { + Entry.Text = MoveTemp(CachedText); + bHit = true; + } + } + if (!bHit) + { + FFileHelper::LoadFileToString(Entry.Text, *F.FullPath); + ChangedExts.Add(F.Ext); + ++FromDisk; + } + else + { + ++FromCache; + } + NewEntries.Add(F.Key, MoveTemp(Entry)); + } + // Un fichier supprime change aussi son type. + for (const auto& Pair : Cached) + { + for (const auto& File : Pair.Value->Values) + { + if (!NewEntries.Contains(File.Key)) { ChangedExts.Add(Pair.Key); } + } + } + + // 4. Reecriture des types modifies : texte, date, taille et, pour les formats "CLE: valeur", champs + calque. + IFileManager::Get().MakeDirectory(*CacheDir, true); + for (const FString& Ext : ChangedExts) + { + const TSharedPtr* DefaultsPtr = nullptr; + TSharedPtr Defaults; + if (AllDefaults.IsValid() && AllDefaults->TryGetObjectField(Ext, DefaultsPtr)) { Defaults = *DefaultsPtr; } + + TSharedRef Files = MakeShared(); + for (const FFound& F : Found) + { + if (F.Ext != Ext) { continue; } + const FEntry& E = NewEntries[F.Key]; + TSharedRef Obj = MakeShared(); + Obj->SetStringField(TEXT("stamp"), E.Stamp.ToIso8601()); + Obj->SetNumberField(TEXT("size"), (double)E.Size); + Obj->SetStringField(TEXT("text"), E.Text); + if (IsKeyValueFormat(Ext)) + { + Obj->SetObjectField(TEXT("fields"), ParseFields(E.Text, Defaults)); + } + Files->SetObjectField(F.Key, Obj); + } + TSharedRef Root = MakeShared(); + Root->SetNumberField(TEXT("version"), CacheVersion); + Root->SetStringField(TEXT("type"), Ext); + Root->SetObjectField(TEXT("files"), Files); + FString Out; + FJsonSerializer::Serialize(Root, TJsonWriterFactory<>::Create(&Out)); + FFileHelper::SaveStringToFile(Out, *(CacheDir / (Ext + TEXT(".json"))), FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); + } + + Entries = MoveTemp(NewEntries); + bReady = true; + UE_LOG(LogLPDataCache, Display, TEXT("Cache de donnees pret : %d fichier(s) (%d depuis le cache, %d relus sur le disque), %d type(s) reecrit(s), releve %.3f s, total %.3f s"), + Entries.Num(), FromCache, FromDisk, ChangedExts.Num(), ScanSeconds, FPlatformTime::Seconds() - Start); +} + +bool FLPDataCache::TryGet(const FString& ContentRelativePath, FString& OutText) +{ + if (bDisabled || !bReady) + { + return false; + } + const FString Key = MakeKey(ContentRelativePath); + if (!Key.StartsWith(TEXT("modules/")) || WrittenBeforeReady.Contains(Key)) + { + return false; + } + const FEntry* Entry = Entries.Find(Key); + if (!Entry) + { + return false; + } + OutText = Entry->Text; + return true; +} + +void FLPDataCache::OnWritten(const FString& ContentRelativePath, const FString& Text) +{ + const FString Key = MakeKey(ContentRelativePath); + if (!Key.StartsWith(TEXT("modules/"))) + { + return; + } + if (!bReady) + { + WrittenBeforeReady.Add(Key); + return; + } + if (FEntry* Entry = Entries.Find(Key)) + { + // Relu tel que LoadTxt le lirait (encodage d'ecriture et de lecture identiques a l'usage normal). + FFileHelper::LoadFileToString(Entry->Text, *(FPaths::ProjectContentDir() + ContentRelativePath)); + } + else + { + FEntry NewEntry; + FFileHelper::LoadFileToString(NewEntry.Text, *(FPaths::ProjectContentDir() + ContentRelativePath)); + Entries.Add(Key, MoveTemp(NewEntry)); + } +} diff --git a/Plugins/ModdingExtra/Source/ModdingExtra/Private/LPDataCache.h b/Plugins/ModdingExtra/Source/ModdingExtra/Private/LPDataCache.h new file mode 100644 index 0000000..cf157f9 --- /dev/null +++ b/Plugins/ModdingExtra/Source/ModdingExtra/Private/LPDataCache.h @@ -0,0 +1,55 @@ +// Portage UE 5.5 - couche de donnees, etapes 2 et 3 (Docs/Migration/11_Audit_Optimisation.md §17). +// +// Les fichiers texte de Content/Modules (jeu de base et mods) restent la source. Au demarrage du jeu, une tache +// d'arriere-plan : +// 1. releve la date et la taille de chaque fichier de Modules (sans les ouvrir) ; +// 2. charge le cache compile : un fichier JSON par type (Saved/LPDataCache/.json), contenant le texte de chaque +// fichier et, pour les formats "CLE: valeur", ses champs analyses completes par un calque de valeurs par defaut +// (fichier optionnel Content/Modules/_lpdata_defaults.json, {"lpaction": {"CLE": "valeur"}, ...}) ; +// 3. relit sur le disque les seuls fichiers nouveaux ou modifies, puis reecrit le cache s'il a change. +// Ensuite, LoadTxt sert le texte de Modules depuis la memoire (texte identique a FFileHelper::LoadFileToString) et +// SaveTxt la met a jour. Avant la fin du chargement, ou pour tout chemin hors Modules, la lecture se fait sur le disque. +// +// Options de ligne de commande : -LPNoDataCache (desactive), -LPDataCacheVerify (compare chaque texte servi a une +// lecture du disque et journalise les ecarts). + +#pragma once + +#include "CoreMinimal.h" +#include + +class FLPDataCache +{ +public: + static FLPDataCache& Get(); + + /** Lance le chargement en arriere-plan (une fois). */ + void StartAsync(); + + /** Texte d'un fichier de Modules depuis la memoire. Faux si absent, cache pas pret ou desactive. Thread de jeu. */ + bool TryGet(const FString& ContentRelativePath, FString& OutText); + + /** Mise a jour apres une ecriture (SaveTxt). Thread de jeu. */ + void OnWritten(const FString& ContentRelativePath, const FString& Text); + + /** Vrai si -LPDataCacheVerify. */ + bool IsVerifyEnabled() const { return bVerify; } + +private: + struct FEntry + { + FDateTime Stamp; + int64 Size = 0; + FString Text; + }; + + void LoadAll(); + static FString MakeKey(const FString& ContentRelativePath); + + TMap Entries; // cle : chemin relatif a Content, en minuscules, separateurs '/' + TSet WrittenBeforeReady; // ecrits par SaveTxt avant la fin du chargement : lus sur le disque + std::atomic bReady{false}; + bool bStarted = false; + bool bDisabled = false; + bool bVerify = false; +}; diff --git a/Plugins/ModdingExtra/Source/ModdingExtra/Private/ModdingExtra.cpp b/Plugins/ModdingExtra/Source/ModdingExtra/Private/ModdingExtra.cpp index 9bc175a..d07f804 100644 --- a/Plugins/ModdingExtra/Source/ModdingExtra/Private/ModdingExtra.cpp +++ b/Plugins/ModdingExtra/Source/ModdingExtra/Private/ModdingExtra.cpp @@ -1,13 +1,18 @@ // Copyright 1998-2017 Epic Games, Inc. All Rights Reserved. #include "ModdingExtra.h" +#include "LPDataCache.h" +#include "Misc/CommandLine.h" #define LOCTEXT_NAMESPACE "FModdingExtraModule" void FModdingExtraModule::StartupModule() { - // This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module - + // Portage 5.5, couche de donnees : chargement de Modules en arriere-plan des le demarrage (pas dans les commandlets). + if (!IsRunningCommandlet()) + { + FLPDataCache::Get().StartAsync(); + } } void FModdingExtraModule::ShutdownModule() diff --git a/Plugins/ModdingExtra/Source/ModdingExtra/Private/ModdingExtraBPLibrary.cpp b/Plugins/ModdingExtra/Source/ModdingExtra/Private/ModdingExtraBPLibrary.cpp index f9732b6..b90cc27 100644 --- a/Plugins/ModdingExtra/Source/ModdingExtra/Private/ModdingExtraBPLibrary.cpp +++ b/Plugins/ModdingExtra/Source/ModdingExtra/Private/ModdingExtraBPLibrary.cpp @@ -5,6 +5,7 @@ #include "AssetRegistry/AssetRegistryModule.h" #include "HAL/FileManager.h" #include "Engine/World.h" +#include "LPDataCache.h" #include "HAL/IConsoleManager.h" #include "HAL/PlatformTime.h" #include "Misc/CoreDelegates.h" @@ -152,7 +153,7 @@ namespace LPLoadTxtStats static FAutoConsoleCommand Command(TEXT("ModdingExtra.LoadTxtStats"), TEXT("Bilan des lectures LoadTxt par type de fichier."), FConsoleCommandDelegate::CreateLambda([]() { Dump(TEXT("commande console")); })); - static void Record(const FString& FileName, int32 Len, double Seconds) + static void Record(const FString& FileName, int32 Len, double Seconds, bool bCached) { static bool bExitHooked = false; if (!bExitHooked) @@ -165,6 +166,7 @@ namespace LPLoadTxtStats LastCall = Now; FString Ext = FPaths::GetExtension(FileName).ToLower(); if (Ext.IsEmpty()) { Ext = TEXT("(sans)"); } + if (bCached) { Ext += TEXT(" (cache)"); } FEntry& E = ByExt.FindOrAdd(Ext); ++E.Calls; E.Bytes += Len; E.Seconds += Seconds; bool bAlready = false; @@ -176,8 +178,24 @@ namespace LPLoadTxtStats bool UModdingExtraBPLibrary::LoadTxt(FString FileNameA, FString& SaveTextA) { const double StartTime = FPlatformTime::Seconds(); - const bool bOk = FFileHelper::LoadFileToString(SaveTextA, *(FPaths::ProjectContentDir() + FileNameA)); - LPLoadTxtStats::Record(FileNameA, SaveTextA.Len(), FPlatformTime::Seconds() - StartTime); + // Couche de donnees (etapes 2 et 3) : fichiers de Modules servis depuis la memoire quand le cache est pret. + FLPDataCache& Cache = FLPDataCache::Get(); + const bool bFromCache = Cache.TryGet(FileNameA, SaveTextA); + const bool bOk = bFromCache || FFileHelper::LoadFileToString(SaveTextA, *(FPaths::ProjectContentDir() + FileNameA)); + LPLoadTxtStats::Record(FileNameA, SaveTextA.Len(), FPlatformTime::Seconds() - StartTime, bFromCache); + if (bFromCache && Cache.IsVerifyEnabled()) + { + FString FromDisk; + FFileHelper::LoadFileToString(FromDisk, *(FPaths::ProjectContentDir() + FileNameA)); + if (!FromDisk.Equals(SaveTextA, ESearchCase::CaseSensitive)) + { + UE_LOG(LogModdingExtra, Error, TEXT("LoadTxt CACHE ECART | '%s' : %d car. en cache, %d sur le disque"), *FileNameA, SaveTextA.Len(), FromDisk.Len()); + } + else + { + UE_LOG(LogModdingExtra, Verbose, TEXT("LoadTxt CACHE identique | '%s'"), *FileNameA); + } + } if (!bOk) { @@ -210,6 +228,10 @@ namespace LPLoadTxtStats bool UModdingExtraBPLibrary::SaveTxt(FString SaveTextB, FString FileNameB) { const bool bOk = FFileHelper::SaveStringToFile(SaveTextB, *(FPaths::ProjectContentDir() + FileNameB)); + if (bOk) + { + FLPDataCache::Get().OnWritten(FileNameB, SaveTextB); + } if (!bOk) { UE_LOG(LogModdingExtra, Warning, TEXT("SaveTxt ECHEC | '%s' (%d car.) : ecriture impossible"), *FileNameB, SaveTextB.Len());