// Lot U7 (U7-T7) - voir LPModGuard.h // // API moteur lues dans UE 5.8.3 (Engine/Source) : montage des plugins PluginManager.cpp:1965-1979 et :2015-2018 ; // UnmountExplicitlyLoadedPlugin (reserve aux plugins ExplicitlyLoaded) :3650-3739 ; FPackageName::UnRegisterMountPoint // PackageName.h:347, PackageName.cpp:426-462 ; purge du registre AssetRegistry.cpp:9241-9327 ; FPakPlatformFile::Unmount // IPlatformFilePak.cpp:6288-6356, delegue OnUnmountPak CoreDelegates.h:128, IPlatformFilePak.cpp:5808. #include "LPModGuard.h" #include "AssetRegistry/AssetData.h" #include "AssetRegistry/IAssetRegistry.h" #include "CoreGlobals.h" #include "HAL/Event.h" #include "HAL/FileManager.h" #include "HAL/PlatformFileManager.h" #include "HAL/PlatformProperties.h" #include "Interfaces/IPluginManager.h" #include "IO/IoChunkId.h" #include "IO/IoContainerHeader.h" #include "IO/IoContainerId.h" #include "IO/IoDispatcher.h" #include "LifePlay.h" #include "Misc/ConfigCacheIni.h" #include "Misc/CoreDelegates.h" #include "Misc/EngineVersion.h" #include "Misc/FileHelper.h" #include "Misc/PackageName.h" #include "Misc/Paths.h" // A relever au prochain changement de moteur (premiere version du jeu cuite par ce moteur). const TCHAR* const FLPModGuard::MinGameVersion = TEXT("2026.10.2.0"); namespace LPModGuardImpl { constexpr int32 NumChecks = static_cast(ELPModCheck::Num); const TCHAR* const SettingsSection = TEXT("/Script/LifePlay.LPModInfoSubsystem"); const TCHAR* const CheckKeys[NumChecks] = { TEXT("bCheckModEngine"), TEXT("bCheckModMinVersion"), TEXT("bCheckModMaxVersion") }; const TCHAR* const CheckNames[NumChecks] = { TEXT("moteur"), TEXT("version min"), TEXT("version max") }; bool GRan = false; bool GActive[NumChecks] = { true, true, true }; FString GGameVersion; TArray GVerdicts; struct FFailure { ELPModCheck Check = ELPModCheck::Engine; FString En; FString Fr; }; /** "2026.10.2.0" -> {2026, 10, 2, 0} ; faux si une composante n'est pas un entier (comparaison numerique, pas lexicale). * Lot REL-ALPHA (decision de l'utilisateur "Lettre acceptee", 2026-10-14) : la derniere composante peut finir par des * lettres ASCII a-z / A-Z, retirees avant les controles et ignorees a la comparaison : "2026.10.2.1a" -> {2026, 10, 2, 1}. */ bool ParseVersion(const FString& Text, TArray& Out) { Out.Reset(); TArray Parts; Text.TrimStartAndEnd().ParseIntoArray(Parts, TEXT("."), false); // Lettres de fin de la derniere composante seulement, par plages explicites (aucune lettre non ASCII retiree). if (Parts.Num() > 0) { FString& LastPart = Parts.Last(); int32 Keep = LastPart.Len(); while (Keep > 0) { const TCHAR C = LastPart[Keep - 1]; if (!((C >= TEXT('a') && C <= TEXT('z')) || (C >= TEXT('A') && C <= TEXT('Z')))) { break; } --Keep; } LastPart.LeftInline(Keep); } for (const FString& Part : Parts) { if (Part.IsEmpty() || Part.Len() > 9) { return false; } for (const TCHAR C : Part) { if (!FChar::IsDigit(C)) { return false; } } Out.Add(FCString::Atoi64(*Part)); } return Out.Num() > 0; } /** <0, 0, >0 ; une composante absente vaut 0 (2026.10.4 == 2026.10.4.0). */ int32 CompareVersions(const TArray& A, const TArray& B) { const int32 Count = FMath::Max(A.Num(), B.Num()); for (int32 Index = 0; Index < Count; ++Index) { const int64 Left = A.IsValidIndex(Index) ? A[Index] : 0; const int64 Right = B.IsValidIndex(Index) ? B[Index] : 0; if (Left != Right) { return Left < Right ? -1 : 1; } } return 0; } bool ReadCheck(int32 Index) { bool bValue = true; if (GConfig) { GConfig->GetBool(SettingsSection, CheckKeys[Index], bValue, GGameUserSettingsIni); } return bValue; } /** Paks du plugin : meme recherche que FPluginManager au montage (PluginManager.cpp:1965-1979), donc les memes * chaines que celles passees a FCoreDelegates::MountPak (FPakPlatformFile::Unmount compare le nom exact). */ TArray FindPluginPaks(const IPlugin& Plugin) { TArray Paks; const FString Dir = Plugin.GetContentDir() / TEXT("Paks") / FString(FPlatformProperties::PlatformName()); FPlatformFileManager::Get().GetPlatformFile().IterateDirectoryRecursively(*Dir, [&Paks](const TCHAR* Name, bool bIsDirectory) { const FString File(Name); if (!bIsDirectory && File.MatchesWildcard(TEXT("*.pak"))) { Paks.AddUnique(File); } return true; }); return Paks; } /** Bloc ContainerHeader du conteneur IoStore d'un pak : identifiant = FromName(nom de base du .utoc) (U7-T6 section 3, * verifie sur 6 conteneurs ; Id du journal T4). */ FIoChunkId HeaderChunkOf(const FString& PakPath) { return CreateContainerHeaderChunkId(FIoContainerId::FromName(FName(*FPaths::GetBaseFilename(PakPath)))); } /** Version de l'en-tete de conteneur (Signature puis Version, IoContainerHeader.cpp:51-58) ; -1 si illisible. */ int32 ReadContainerHeaderVersion(const FString& PakPath) { if (!FIoDispatcher::IsInitialized() || !IFileManager::Get().FileExists(*FPaths::ChangeExtension(PakPath, TEXT("utoc")))) { return -1; } const FIoChunkId Chunk = HeaderChunkOf(PakPath); FIoDispatcher& Dispatcher = FIoDispatcher::Get(); if (!Dispatcher.DoesChunkExist(Chunk)) { return -1; } FIoBatch Batch = Dispatcher.NewBatch(); FIoRequest Request = Batch.Read(Chunk, FIoReadOptions(0, 8), IoDispatcherPriority_High); FEventRef Done; Batch.IssueAndTriggerEvent(Done.Get()); Done->Wait(); // Nul en cas d'echec (IoDispatcher.cpp:1443-1461) : pas de GetResultOrDie, qui arreterait le jeu. const FIoBuffer* Buffer = Request.GetResult(); if (!Buffer || Buffer->DataSize() < 8) { return -1; } uint32 Words[2] = { 0, 0 }; FMemory::Memcpy(Words, Buffer->Data(), sizeof(Words)); return Words[0] == static_cast(FIoContainerHeader::Signature) ? static_cast(Words[1]) : -1; } /** Version de Mods//AssetRegistry.bin (dans le pak du mod, meme fichier que le registre d'assets du moteur, * AssetRegistry.cpp:642) ; -1 si illisible. */ int32 ReadAssetRegistryVersion(const IPlugin& Plugin) { TUniquePtr Reader(IFileManager::Get().CreateFileReader(*(Plugin.GetBaseDir() / TEXT("AssetRegistry.bin")), FILEREAD_Silent)); if (!Reader) { return -1; } FAssetRegistryVersion::Type Version = FAssetRegistryVersion::PreVersioning; if (!FAssetRegistryVersion::SerializeVersion(*Reader, Version) || Reader->IsError()) { return -1; } return static_cast(Version); } /** Retire le point de montage // puis demonte les paks du plugin ; vrai si rien du mod n'est plus accessible. */ bool UnloadPlugin(const IPlugin& Plugin, const TArray& Paks) { const FString Root = Plugin.GetMountedAssetPath(); // Etape de contenu de FPluginManager::UnmountExplicitlyLoadedPlugin (PluginManager.cpp:3716), fonction refusee ici // car le plugin n'est pas ExplicitlyLoaded (:3660-3667) ; memes arguments qu'au montage (:2015-2018). Le registre // d'assets retire alors tous les assets du point de montage (AssetRegistry.cpp:9241-9327 ; chemin non persistant : // UpdatePersistentMountPoints ne voit que le registre principal, :623, avant ceux des plugins, :633-656). FPackageName::UnRegisterMountPoint(Root, Plugin.GetContentDir()); // Pak, entrees du magasin de paquets et conteneur IoStore (FPakPlatformFile::Unmount, IPlatformFilePak.cpp:6288-6356). int32 Unmounted = 0; for (const FString& Pak : Paks) { if (FCoreDelegates::OnUnmountPak.IsBound() && FCoreDelegates::OnUnmountPak.Execute(Pak)) { ++Unmounted; } } // Controle relu apres coup. const bool bRootGone = !FPackageName::MountPointExists(Root); const IAssetRegistry* Registry = IAssetRegistry::Get(); const bool bRegistryEmpty = !Registry || !Registry->HasAssets(FName(*Root.LeftChop(1)), true); bool bContainersGone = true; for (const FString& Pak : Paks) { if (FIoDispatcher::IsInitialized() && FIoDispatcher::Get().DoesChunkExist(HeaderChunkOf(Pak))) { bContainersGone = false; } } const FString Report = FString::Printf(TEXT("%d pak(s) demonte(s) sur %d, point de montage %s %s, registre %s, conteneur(s) %s"), Unmounted, Paks.Num(), *Root, bRootGone ? TEXT("retire") : TEXT("ENCORE PRESENT"), bRegistryEmpty ? TEXT("vide") : TEXT("NON VIDE"), bContainersGone ? TEXT("absent(s)") : TEXT("ENCORE PRESENT(S)")); const bool bOk = Unmounted == Paks.Num() && bRootGone && bRegistryEmpty && bContainersGone; if (bOk) { UE_LOG(LogLifePlay, Display, TEXT("Modules : %s non charge : %s"), *Plugin.GetName(), *Report); } else { UE_LOG(LogLifePlay, Error, TEXT("Modules : %s demontage incomplet : %s ; ses assets ne sont ecartes que par LPModDailies et LPHairstyleSubsystem"), *Plugin.GetName(), *Report); } return bOk; } bool HasFailure(const TArray& Failures, ELPModCheck Check) { return Failures.ContainsByPredicate([Check](const FFailure& Failure) { return Failure.Check == Check; }); } } void FLPModGuard::Run() { using namespace LPModGuardImpl; if (GRan) { return; } GRan = true; // Cuisson, resave, outils : aucun mod de joueur a controler. if (IsRunningCommandlet() || !GConfig) { return; } // Meme valeur que UGeneralProjectSettings::ProjectVersion, lue sans dependre de l'objet par defaut (demarrage du module). GConfig->GetString(TEXT("/Script/EngineSettings.GeneralProjectSettings"), TEXT("ProjectVersion"), GGameVersion, GGameIni); for (int32 Index = 0; Index < NumChecks; ++Index) { GActive[Index] = ReadCheck(Index); } TArray> Mods; for (const TSharedRef& Plugin : IPluginManager::Get().GetEnabledPluginsWithContent()) { if (Plugin->GetType() == EPluginType::Mod) { Mods.Add(Plugin); } } const bool bCooked = FPlatformProperties::RequiresCookedData(); const int32 GameContainer = static_cast(EIoContainerHeaderVersion::Latest); const int32 GameRegistry = static_cast(FAssetRegistryVersion::LatestVersion); const FEngineVersion& CurrentEngine = FEngineVersion::Current(); const int32 EngineMajor = static_cast(CurrentEngine.GetMajor()); const int32 EngineMinor = static_cast(CurrentEngine.GetMinor()); UE_LOG(LogLifePlay, Display, TEXT("Modules : garde des mods d'assets, %d plugin(s) de type Mod, jeu %s (version min des mods %s), moteur %s (conteneur %d, registre %d)%s"), Mods.Num(), *GGameVersion, MinGameVersion, *CurrentEngine.ToString(), GameContainer, GameRegistry, bCooked ? TEXT("") : TEXT(", jeu non cuit : controle binaire sans objet, rien n'est demonte")); for (int32 Index = 0; Index < NumChecks; ++Index) { if (!GActive[Index]) { UE_LOG(LogLifePlay, Warning, TEXT("Modules : controle %s des mods leve (Settings > Misc, %s)"), CheckNames[Index], *GGameUserSettingsIni); } } TArray GameParts; TArray MinParts; const bool bGameParsed = ParseVersion(GGameVersion, GameParts); ParseVersion(MinGameVersion, MinParts); if (!bGameParsed && Mods.Num() > 0) { UE_LOG(LogLifePlay, Error, TEXT("Modules : ProjectVersion '%s' illisible, controle version max sans objet"), *GGameVersion); } TArray> ModPaks; for (const TSharedRef& Plugin : Mods) { FLPModVerdict Verdict; Verdict.Module = Plugin->GetName(); TMap Keys; FString Text; if (FFileHelper::LoadFileToString(Text, *(FPaths::ProjectContentDir() / TEXT("Modules") / Verdict.Module / Verdict.Module + TEXT(".lpmod")))) { ParseLpmod(Text, Keys); } const FString* Name = Keys.Find(TEXT("MODULE_NAME")); Verdict.DisplayName = Name && !Name->IsEmpty() ? *Name : Plugin->GetFriendlyName(); if (Verdict.DisplayName.IsEmpty()) { Verdict.DisplayName = Verdict.Module; } Verdict.BuiltFor = Keys.FindRef(TEXT("MODULE_GAME_VERSION")); TArray Failures; // 1. Moteur : format binaire (jeu cuit), puis EngineVersion du .uplugin. TArray Paks = bCooked ? FindPluginPaks(*Plugin) : TArray(); FString Formats = bCooked ? TEXT("aucun pak") : TEXT("format non lu"); if (Paks.Num() > 0) { const int32 RegistryVersion = ReadAssetRegistryVersion(*Plugin); int32 ContainerVersion = GameContainer; FString Unreadable = RegistryVersion < 0 ? TEXT("AssetRegistry.bin") : TEXT(""); for (const FString& Pak : Paks) { const int32 PakContainerVersion = ReadContainerHeaderVersion(Pak); if (PakContainerVersion < 0) { Unreadable += (Unreadable.IsEmpty() ? TEXT("") : TEXT(", ")) + FPaths::GetBaseFilename(Pak) + TEXT(".utoc"); } else if (PakContainerVersion != GameContainer) { ContainerVersion = PakContainerVersion; } } Formats = FString::Printf(TEXT("conteneur %d, registre %d"), ContainerVersion, RegistryVersion); if (!Unreadable.IsEmpty()) { Failures.Add(FFailure{ ELPModCheck::Engine, TEXT("mod files unreadable"), FString::Printf(TEXT("moteur : fichiers illisibles (%s)"), *Unreadable) }); } else if (ContainerVersion != GameContainer || RegistryVersion != GameRegistry) { const bool bByContainer = ContainerVersion != GameContainer; const int32 ModFormat = bByContainer ? ContainerVersion : RegistryVersion; const int32 GameFormat = bByContainer ? GameContainer : GameRegistry; Failures.Add(FFailure{ ELPModCheck::Engine, FString::Printf(TEXT("built with %s engine (%sformat %d, game %d): rebuild the mod for LifePlay %s"), ModFormat < GameFormat ? TEXT("an older") : TEXT("a newer"), bByContainer ? TEXT("") : TEXT("asset registry "), ModFormat, GameFormat, *GGameVersion), FString::Printf(TEXT("moteur : conteneur %d (jeu %d), registre %d (jeu %d)"), ContainerVersion, GameContainer, RegistryVersion, GameRegistry) }); } } const FString& Declared = Plugin->GetDescriptor().EngineVersion; if (!Declared.IsEmpty() && !HasFailure(Failures, ELPModCheck::Engine)) { FEngineVersion Parsed; if (!FEngineVersion::Parse(Declared, Parsed)) { Failures.Add(FFailure{ ELPModCheck::Engine, TEXT("mod files unreadable"), FString::Printf(TEXT("moteur : EngineVersion '%s' illisible dans le .uplugin"), *Declared) }); } else if (static_cast(Parsed.GetMajor()) != EngineMajor || static_cast(Parsed.GetMinor()) != EngineMinor) { const bool bOlder = static_cast(Parsed.GetMajor()) < EngineMajor || (static_cast(Parsed.GetMajor()) == EngineMajor && static_cast(Parsed.GetMinor()) < EngineMinor); Failures.Add(FFailure{ ELPModCheck::Engine, FString::Printf(TEXT("built with %s engine (Unreal Engine %s, game %d.%d): rebuild the mod for LifePlay %s"), bOlder ? TEXT("an older") : TEXT("a newer"), *Declared, EngineMajor, EngineMinor, *GGameVersion), FString::Printf(TEXT("moteur : EngineVersion %s (moteur %d.%d)"), *Declared, EngineMajor, EngineMinor) }); } } // 2 et 3. Versions min et max du jeu visees par le mod. if (Verdict.BuiltFor.IsEmpty()) { UE_LOG(LogLifePlay, Warning, TEXT("Modules : %s sans MODULE_GAME_VERSION, controles de version min et max sans objet"), *Verdict.Module); } else { TArray BuiltParts; if (!ParseVersion(Verdict.BuiltFor, BuiltParts)) { Failures.Add(FFailure{ ELPModCheck::MinVersion, FString::Printf(TEXT("unreadable game version '%s' in its .lpmod"), *Verdict.BuiltFor), FString::Printf(TEXT("version min : MODULE_GAME_VERSION '%s' illisible"), *Verdict.BuiltFor) }); } else { if (CompareVersions(BuiltParts, MinParts) < 0) { Failures.Add(FFailure{ ELPModCheck::MinVersion, FString::Printf(TEXT("built for LifePlay %s, minimum is %s"), *Verdict.BuiltFor, MinGameVersion), FString::Printf(TEXT("version min : construit pour LifePlay %s < %s"), *Verdict.BuiltFor, MinGameVersion) }); } if (bGameParsed && CompareVersions(BuiltParts, GameParts) > 0) { Failures.Add(FFailure{ ELPModCheck::MaxVersion, FString::Printf(TEXT("built for LifePlay %s, this game is %s: update the game"), *Verdict.BuiltFor, *GGameVersion), FString::Printf(TEXT("version max : construit pour LifePlay %s > %s"), *Verdict.BuiltFor, *GGameVersion) }); } } } // Echec Moteur actif : les echecs min/max restent au journal seulement (secondaires). La numerotation 5.5 n'est pas // monotone (mods 2026.10.4 > 2026.10.2.0) : la fenetre dirait a tort "update the game". const bool bEngineRefused = GActive[static_cast(ELPModCheck::Engine)] && HasFailure(Failures, ELPModCheck::Engine); TArray Lifted; for (const FFailure& Failure : Failures) { if (GActive[static_cast(Failure.Check)]) { Verdict.bRefused = true; if (bEngineRefused && Failure.Check != ELPModCheck::Engine) { Verdict.LogReasons.Add(Failure.Fr + TEXT(" (secondaire)")); } else { Verdict.Reasons.Add(Failure.En); Verdict.LogReasons.Add(Failure.Fr); } } else { Lifted.Add(Failure.Fr); } } const FString Summary = FString::Printf(TEXT("%s, EngineVersion %s, construit pour LifePlay %s"), *Formats, Declared.IsEmpty() ? TEXT("absent") : *Declared, Verdict.BuiltFor.IsEmpty() ? TEXT("?") : *Verdict.BuiltFor); const FString LiftedNote = Lifted.Num() > 0 ? TEXT(" ; controle leve : ") + FString::Join(Lifted, TEXT(" ; ")) : FString(); if (Verdict.bRefused) { // Une ligne Warning par mod refuse. UE_LOG(LogLifePlay, Warning, TEXT("Modules : %s refuse : %s%s"), *Verdict.Module, *FString::Join(Verdict.LogReasons, TEXT(" ; ")), *LiftedNote); } else if (Lifted.Num() > 0) { UE_LOG(LogLifePlay, Warning, TEXT("Modules : %s charge malgre : %s (controle leve dans Settings > Misc)"), *Verdict.Module, *FString::Join(Lifted, TEXT(" ; "))); } else { UE_LOG(LogLifePlay, Display, TEXT("Modules : %s accepte (%s)"), *Verdict.Module, *Summary); } GVerdicts.Add(MoveTemp(Verdict)); ModPaks.Add(MoveTemp(Paks)); } // Jeu cuit : un mod refuse n'est pas charge. L'editeur ne demonte rien (contenu non cuit, pas de pak). if (!bCooked) { return; } bool bWaited = false; for (int32 Index = 0; Index < GVerdicts.Num(); ++Index) { if (!GVerdicts[Index].bRefused) { continue; } if (!bWaited) { // Le registre des plugins peut se charger en tache de fond (AssetRegistry.cpp:377-428) : il doit etre complet // avant le retrait du point de montage, sinon celui du mod serait ajoute apres la purge. if (IAssetRegistry* Registry = IAssetRegistry::Get()) { Registry->WaitForPremadeAssetRegistry(); } bWaited = true; } GVerdicts[Index].bUnloaded = UnloadPlugin(*Mods[Index], ModPaks[Index]); } } const TArray& FLPModGuard::GetVerdicts() { Run(); return LPModGuardImpl::GVerdicts; } bool FLPModGuard::IsRefused(const FString& Module) { for (const FLPModVerdict& Verdict : GetVerdicts()) { if (Verdict.bRefused && Verdict.Module.Equals(Module, ESearchCase::IgnoreCase)) { return true; } } return false; } bool FLPModGuard::IsCheckActive(ELPModCheck Check) { Run(); const int32 Index = static_cast(Check); return Index >= 0 && Index < LPModGuardImpl::NumChecks ? LPModGuardImpl::GActive[Index] : true; } bool FLPModGuard::GetSavedCheck(ELPModCheck Check) { const int32 Index = static_cast(Check); return Index >= 0 && Index < LPModGuardImpl::NumChecks ? LPModGuardImpl::ReadCheck(Index) : true; } void FLPModGuard::SaveCheck(ELPModCheck Check, bool bEnabled) { using namespace LPModGuardImpl; const int32 Index = static_cast(Check); if (Index < 0 || Index >= NumChecks || !GConfig) { return; } GConfig->SetBool(SettingsSection, CheckKeys[Index], bEnabled, GGameUserSettingsIni); GConfig->Flush(false, GGameUserSettingsIni); UE_LOG(LogLifePlay, Display, TEXT("Modules : controle %s des mods %s dans %s, effet au prochain lancement"), CheckNames[Index], bEnabled ? TEXT("active") : TEXT("leve"), *GGameUserSettingsIni); } const FString& FLPModGuard::GetGameVersion() { Run(); return LPModGuardImpl::GGameVersion; } void FLPModGuard::ParseLpmod(const FString& Text, TMap& OutKeys) { TArray Lines; Text.ParseIntoArrayLines(Lines, true); for (const FString& Line : Lines) { FString Key, Value; if (!Line.TrimStart().StartsWith(TEXT("//")) && Line.Split(TEXT(":"), &Key, &Value)) { OutKeys.Add(Key.TrimStartAndEnd().ToUpper(), Value.TrimStartAndEnd()); } } }