diff --git a/Docs/Migration/11_Audit_Optimisation.md b/Docs/Migration/11_Audit_Optimisation.md index 0ae324f..0fa66d9 100644 --- a/Docs/Migration/11_Audit_Optimisation.md +++ b/Docs/Migration/11_Audit_Optimisation.md @@ -534,6 +534,48 @@ plugin `HairStrands` n'est plus actif dans le jeu depuis O1. Restent ≈ 12 mail 5. Test en jeu : personnage `.lpcharacter` avec `HAIR Hairstyle: `, menu de création, suivi des animations. Morphs de forme : hors périmètre du premier essai. +### 16.7 H1 : coiffures déclarées par fichiers `.lphair` (2026-10-02) + +Demande utilisateur : une table figée dans un Blueprint ne se prête pas au modding ; externaliser la liste des +coiffures. Réalisation sans modifier de Blueprint : `Source/LifePlay/LPHairstyleSubsystem` (sous-système de +`GameInstance`). + +**Format** : un fichier par coiffure, `Modules//**/*.lphair` : + +``` +HAIRSTYLE: /Game/Heavy/New5/Hairs/Exemple.Exemple +SEX: F (F ou M, défaut F) +DAZ: true (défaut true) +FIT_MORPH: Exemple__AllExpand 0.45 (facultatif) +``` + +**Fonctionnement** + +- au démarrage, relevé des fichiers `.lphair` de `Modules` ; sans fichier, le sous-système ne fait rien ; +- chaque coiffure d'un module actif (`GI.ModEnableds`) est ajoutée **en fin** de `GI.HairstyleTable` ; son morph + d'ajustement éventuel est ajouté à `GI.MorphableHairs` ; +- contrôles : maillage présent dans le registre d'assets et de classe `SkeletalMesh`, chemin absent de la table + (pas de doublon) ; chaque refus est journalisé (`LogLifePlay`) ; +- les entrées du Blueprint ne sont jamais modifiées ni réordonnées ; les personnages stockent la coiffure par nom : + les sauvegardes restent compatibles ; +- la liste des modules actifs est relue toutes les 2 s : activer ou désactiver un module dans le gestionnaire ajoute + ou retire ses coiffures (les entrées ajoutées sont retirées puis reconstruites). + +**Premier module** : `Modules/lp_Revival` (`lp_Revival.lpmod`, désactivable), qui déclare la coiffure de test +`/Game/Heavy/New5/Hairs/H1_TestHair` : copie de `New4/Hairs/FloraHair_New` sous un nouveau nom, donc absente de la +table (à remplacer par une coiffure Fab, lot O7e). Les 88 coiffures présentes dans le projet sont toutes dans la table. + +**Vérifications** (build `LifePlay_Build_5.5_H1`, journal du jeu) : + +- doublons : deux fichiers d'essai désignant `soldier` et `armani` (déjà dans la table sous `Soldier`, `Armani`) sont + refusés avec « déjà dans la table » ; la comparaison des chemins ignore la casse, comme les noms d'assets d'UE ; +- ajout : `H1_TestHair ajoutee (index 126, F, module lp_Revival)`, table de 127 entrées ; +- piège de build : avec `-iterate`, la mise en place ne supprime pas les fichiers retirés de `Modules` ; un fichier + supprimé doit l'être aussi du build (ou le build refait à neuf avant publication). + +**Limite** : le maillage doit être importé dans le projet et cuit avec le jeu ; un mod ne peut pas encore apporter +son propre maillage (étape H3 : paquets `.pak` montés depuis `Modules//`). + ## 17. Couche de données : stratégie (2026-10-02) Proposition utilisateur : convertir les fichiers texte de `Modules` en fichiers JSON groupés par type, avec un calque de diff --git a/Docs/Migration/Journal_Modifications.md b/Docs/Migration/Journal_Modifications.md index 6b800fd..aed1e0b 100644 --- a/Docs/Migration/Journal_Modifications.md +++ b/Docs/Migration/Journal_Modifications.md @@ -928,3 +928,4 @@ Validation L7c/L7d (2026-10-02, parc, même cadrage) : la nuit reste claire (sol | CHG-081 | 2026-10-02 | O7d | nouveau `Tools/BuildCollections.py` ; 13 collections statiques `Content/Collections/LifePlay*.collection` (hors dépôt, régénérables) ; `11_Audit_Optimisation.md` §16.5, `08_Guide_Developpeur.md` §2.1, `09_Commandes_Utiles.md` §3 | suite d'O7 (choix utilisateur) : regrouper les 8 561 assets par thème dans l'éditeur sans renommer de dossier ; collection transversale `LifePlay_Chemins_Fixes` (4 984 assets à ne pas déplacer) ; 13 545 chemins, 0 inconnu, chargées par l'éditeur ; aucun asset modifié, pas de build | supprimer `Content/Collections/LifePlay*.collection` (`git checkout O7d-avant` pour le reste) | | CHG-082 | 2026-10-02 | O7d | `Tools/BuildCollections.py` : collections écrites dans `Saved/Collections` (locales) au lieu de `Content/Collections` (partagées) ; 13 fichiers déplacés vers `D:\Game\LifePlay_Backup_5.5\O7d\Content_Collections` puis régénérés (identiques) ; `11_Audit_Optimisation.md` §16.5, `08_Guide_Developpeur.md` §2.1 | avertissement à l'ouverture de l'éditeur (utilisateur) : une collection partagée exige un fournisseur de contrôle de révision, désactivé dans ce projet ; une collection locale n'en utilise pas ; validé par l'utilisateur le 2026-10-02 (plus d'avertissement) | `git checkout O7d-apres -- Tools/BuildCollections.py` et remettre les fichiers sauvegardés | | CHG-083 | 2026-10-02 | O7e | `11_Audit_Optimisation.md` §16.6 | étude en lecture seule de l'intégration d'une coiffure Fab (demande utilisateur) : liste des coiffures = valeur par défaut `GI.HairstyleTable` (126 entrées, 35 absentes déjà en 4.27), suivi par noms d'os Genesis 8, morphs de forme facultatifs, sauvegardes par nom ; 34 coiffures Fab, ≈ 12 maillages utilisables | — (documentation) | +| CHG-084 | 2026-10-02 | H1 | nouveaux `Source/LifePlay/LPHairstyleSubsystem.h/.cpp`, module `Content/Modules/lp_Revival` (`lp_Revival.lpmod`, `Hairstyles/h1_testhair.lphair`) ; asset de test `Content/Heavy/New5/Hairs/H1_TestHair` (copie de `FloraHair_New`, hors dépôt) ; `11_Audit_Optimisation.md` §16.7 | liste des coiffures ouverte aux mods (demande utilisateur) : fichiers `.lphair` ajoutés en fin de `GI.HairstyleTable` sans modifier de Blueprint ; vérifié au journal : doublons refusés, 1 coiffure ajoutée (table 126 → 127) ; build `LifePlay_Build_5.5_H1` ; test en jeu en attente | supprimer le module `lp_Revival` (ou le désactiver) ; `git checkout H1-avant -- Source` | diff --git a/Source/LifePlay/LPHairstyleSubsystem.cpp b/Source/LifePlay/LPHairstyleSubsystem.cpp new file mode 100644 index 0000000..0dbb505 --- /dev/null +++ b/Source/LifePlay/LPHairstyleSubsystem.cpp @@ -0,0 +1,322 @@ +// Portage UE 5.5 - voir LPHairstyleSubsystem.h + +#include "LPHairstyleSubsystem.h" + +#include "AssetRegistry/IAssetRegistry.h" +#include "Engine/GameInstance.h" +#include "Engine/SkeletalMesh.h" +#include "HAL/FileManager.h" +#include "HAL/PlatformTime.h" +#include "LifePlay.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "UObject/UnrealType.h" + +namespace +{ + const FName HairstyleTableName(TEXT("HairstyleTable")); + const FName MorphableHairsName(TEXT("MorphableHairs")); + const FName ModEnabledsName(TEXT("ModEnableds")); + constexpr double CheckInterval = 2.0; + + /** Champ d'une structure Blueprint par son nom d'auteur (les noms internes portent un suffixe GUID). */ + const FProperty* FindField(const UStruct* Struct, const TCHAR* AuthoredName) + { + for (TFieldIterator It(Struct); It; ++It) + { + if (It->GetAuthoredName() == AuthoredName) + { + return *It; + } + } + return nullptr; + } + + /** Affecte un champ a partir de son texte (FName, FString, bool, flottant : meme syntaxe que les valeurs par defaut). */ + bool SetField(const UStruct* Struct, void* Data, const TCHAR* AuthoredName, const FString& Text) + { + const FProperty* Prop = FindField(Struct, AuthoredName); + return Prop && Prop->ImportText_InContainer(*Text, Data, nullptr, PPF_None) != nullptr; + } + + FString GetFieldText(const UStruct* Struct, const void* Data, const TCHAR* AuthoredName) + { + FString Out; + if (const FProperty* Prop = FindField(Struct, AuthoredName)) + { + Prop->ExportTextItem_InContainer(Out, Data, nullptr, nullptr, PPF_None); + } + return Out.TrimQuotes(); + } + + const FArrayProperty* StructArray(const UObject* GI, FName Name, const UScriptStruct*& OutStruct) + { + const FArrayProperty* Prop = GI ? CastField(GI->GetClass()->FindPropertyByName(Name)) : nullptr; + const FStructProperty* Inner = Prop ? CastField(Prop->Inner) : nullptr; + OutStruct = Inner ? Inner->Struct : nullptr; + return OutStruct ? Prop : nullptr; + } +} + +void ULPHairstyleSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + ScanFiles(); + if (Defs.Num() == 0) + { + return; + } + LastSignature = EnabledSignature(); + Sync(); + TickHandle = FTSTicker::GetCoreTicker().AddTicker(FTickerDelegate::CreateUObject(this, &ULPHairstyleSubsystem::Tick)); +} + +void ULPHairstyleSubsystem::Deinitialize() +{ + FTSTicker::GetCoreTicker().RemoveTicker(TickHandle); + Super::Deinitialize(); +} + +bool ULPHairstyleSubsystem::Tick(float DeltaTime) +{ + const double Now = FPlatformTime::Seconds(); + if (Now < NextCheck) + { + return true; + } + NextCheck = Now + CheckInterval; + const FString Signature = EnabledSignature(); + if (Signature != LastSignature) + { + LastSignature = Signature; + UE_LOG(LogLifePlay, Log, TEXT("Coiffures : liste des modules actifs modifiee, resynchronisation")); + Sync(); + } + return true; +} + +void ULPHairstyleSubsystem::ScanFiles() +{ + const FString ModulesDir = FPaths::ConvertRelativePathToFull(FPaths::ProjectContentDir() / TEXT("Modules")); + TArray Files; + IFileManager::Get().FindFilesRecursive(Files, *ModulesDir, TEXT("*.lphair"), true, false); + Files.Sort(); + for (const FString& File : Files) + { + FString Relative = File; + FPaths::MakePathRelativeTo(Relative, *(ModulesDir + TEXT("/"))); + FString Module, Rest; + if (!Relative.Split(TEXT("/"), &Module, &Rest)) + { + UE_LOG(LogLifePlay, Warning, TEXT("Coiffures : %s ignore (doit etre dans un dossier de module)"), *Relative); + continue; + } + FString Text; + if (!FFileHelper::LoadFileToString(Text, *File)) + { + UE_LOG(LogLifePlay, Warning, TEXT("Coiffures : lecture impossible de %s"), *Relative); + continue; + } + FHairDef Def; + Def.File = Relative; + Def.Module = Module; + TArray Lines; + Text.ParseIntoArrayLines(Lines, true); + for (const FString& RawLine : Lines) + { + const FString Line = RawLine.TrimStartAndEnd(); + FString Key, Value; + if (Line.StartsWith(TEXT("//")) || !Line.Split(TEXT(":"), &Key, &Value)) + { + continue; + } + Key = Key.TrimStartAndEnd().ToUpper(); + Value = Value.TrimStartAndEnd(); + if (Key == TEXT("HAIRSTYLE")) + { + Def.Path = Value; + } + else if (Key == TEXT("SEX")) + { + Def.bMale = Value.StartsWith(TEXT("M"), ESearchCase::IgnoreCase); + } + else if (Key == TEXT("DAZ")) + { + Def.bDaz = !Value.Equals(TEXT("false"), ESearchCase::IgnoreCase); + } + else if (Key == TEXT("FIT_MORPH")) + { + Value.Split(TEXT(" "), &Def.FitMorph, &Def.FitValue); + Def.FitValue.TrimStartAndEndInline(); + } + } + if (Def.Path.IsEmpty()) + { + UE_LOG(LogLifePlay, Warning, TEXT("Coiffures : %s sans ligne HAIRSTYLE, ignore"), *Relative); + continue; + } + // Un chemin de paquet sans nom d'objet (/Game/X/Nom) designe l'objet du meme nom. + if (!Def.Path.Contains(TEXT("."))) + { + Def.Path += TEXT(".") + FPaths::GetBaseFilename(Def.Path); + } + Defs.Add(MoveTemp(Def)); + } + if (Defs.Num() > 0) + { + UE_LOG(LogLifePlay, Display, TEXT("Coiffures : %d fichier(s) .lphair trouve(s) dans Modules"), Defs.Num()); + } +} + +FString ULPHairstyleSubsystem::EnabledSignature() const +{ + const UObject* GI = GetGameInstance(); + const UScriptStruct* Struct = nullptr; + const FArrayProperty* Prop = StructArray(GI, ModEnabledsName, Struct); + if (!Prop) + { + return FString(); + } + FString Signature; + FScriptArrayHelper Helper(Prop, Prop->ContainerPtrToValuePtr(GI)); + for (int32 i = 0; i < Helper.Num(); ++i) + { + Signature += GetFieldText(Struct, Helper.GetRawPtr(i), TEXT("UniqueID")) + TEXT("=") + GetFieldText(Struct, Helper.GetRawPtr(i), TEXT("IsEnabled")) + TEXT(";"); + } + return Signature; +} + +bool ULPHairstyleSubsystem::IsModuleEnabled(const FString& Module) const +{ + const UObject* GI = GetGameInstance(); + const UScriptStruct* Struct = nullptr; + const FArrayProperty* Prop = StructArray(GI, ModEnabledsName, Struct); + if (!Prop) + { + return true; + } + FScriptArrayHelper Helper(Prop, Prop->ContainerPtrToValuePtr(GI)); + for (int32 i = 0; i < Helper.Num(); ++i) + { + if (GetFieldText(Struct, Helper.GetRawPtr(i), TEXT("UniqueID")).Equals(Module, ESearchCase::IgnoreCase)) + { + return GetFieldText(Struct, Helper.GetRawPtr(i), TEXT("IsEnabled")).Equals(TEXT("True"), ESearchCase::IgnoreCase); + } + } + return true; // module absent de la liste : actif, comme pour le jeu +} + +void ULPHairstyleSubsystem::Sync() +{ + UObject* GI = GetGameInstance(); + const UScriptStruct* Struct = nullptr; + const FArrayProperty* TableProp = StructArray(GI, HairstyleTableName, Struct); + const FMapProperty* MapProp = GI ? CastField(GI->GetClass()->FindPropertyByName(MorphableHairsName)) : nullptr; + if (!TableProp) + { + UE_LOG(LogLifePlay, Error, TEXT("Coiffures : GI.HairstyleTable introuvable sur %s, aucune coiffure ajoutee"), *GetNameSafe(GI)); + return; + } + FScriptArrayHelper Table(TableProp, TableProp->ContainerPtrToValuePtr(GI)); + + // 1. Retrait des entrees ajoutees precedemment (en fin de table, reconnues par leur chemin). + for (int32 i = Table.Num() - 1; i >= 0 && Injected.Num() > 0; --i) + { + if (Injected.Last() == GetFieldText(Struct, Table.GetRawPtr(i), TEXT("SkeletalMeshPath"))) + { + Table.RemoveValues(i, 1); + Injected.Pop(); + } + } + Injected.Reset(); + const FStructProperty* MapValueProp = MapProp ? CastField(MapProp->ValueProp) : nullptr; + FScriptMapHelper Map(MapProp, MapProp ? MapProp->ContainerPtrToValuePtr(GI) : nullptr); + if (MapProp) + { + for (const FString& Key : InjectedMorphs) + { + for (FScriptMapHelper::FIterator It(Map); It; ++It) + { + FString Existing; + MapProp->KeyProp->ExportTextItem_Direct(Existing, Map.GetKeyPtr(It), nullptr, nullptr, PPF_None); + if (Existing.TrimQuotes() == Key) + { + Map.RemoveAt(It.GetInternalIndex()); + break; + } + } + } + } + InjectedMorphs.Reset(); + + // 2. Chemins deja presents (Blueprint). + TSet Known; + for (int32 i = 0; i < Table.Num(); ++i) + { + Known.Add(GetFieldText(Struct, Table.GetRawPtr(i), TEXT("SkeletalMeshPath"))); + } + + // 3. Ajout des coiffures des modules actifs. + IAssetRegistry& Registry = IAssetRegistry::GetChecked(); + int32 Added = 0, Skipped = 0; + for (const FHairDef& Def : Defs) + { + if (!IsModuleEnabled(Def.Module)) + { + ++Skipped; + continue; + } + if (Known.Contains(Def.Path)) + { + UE_LOG(LogLifePlay, Log, TEXT("Coiffures : %s deja dans la table (%s)"), *Def.Path, *Def.File); + ++Skipped; + continue; + } + const FAssetData Asset = Registry.GetAssetByObjectPath(FSoftObjectPath(Def.Path)); + if (!Asset.IsValid() || !Asset.IsInstanceOf(USkeletalMesh::StaticClass())) + { + UE_LOG(LogLifePlay, Warning, TEXT("Coiffures : %s introuvable ou n'est pas un SkeletalMesh (%s), ignore"), *Def.Path, *Def.File); + ++Skipped; + continue; + } + const int32 Index = Table.AddValue(); + void* Entry = Table.GetRawPtr(Index); + const bool bOk = SetField(Struct, Entry, TEXT("SkeletalMeshPath"), Def.Path) + && SetField(Struct, Entry, TEXT("Bool"), Def.bMale ? TEXT("True") : TEXT("False")) + && SetField(Struct, Entry, TEXT("Daz"), Def.bDaz ? TEXT("True") : TEXT("False")); + if (!bOk) + { + Table.RemoveValues(Index, 1); + UE_LOG(LogLifePlay, Error, TEXT("Coiffures : structure de HairstyleTable inattendue, %s non ajoute"), *Def.Path); + ++Skipped; + continue; + } + Known.Add(Def.Path); + Injected.Add(Def.Path); + ++Added; + + // Morph d'ajustement : cle = nom de l'objet, comme pour les coiffures du Blueprint. + const FString Key = FPaths::GetExtension(Def.Path); + if (!Def.FitMorph.IsEmpty() && MapProp && MapValueProp) + { + bool bExists = false; + for (FScriptMapHelper::FIterator It(Map); It; ++It) + { + FString Existing; + MapProp->KeyProp->ExportTextItem_Direct(Existing, Map.GetKeyPtr(It), nullptr, nullptr, PPF_None); + bExists |= Existing.TrimQuotes() == Key; + } + if (!bExists) + { + const int32 Pair = Map.AddDefaultValue_Invalid_NeedsRehash(); + MapProp->KeyProp->ImportText_Direct(*Key, Map.GetKeyPtr(Pair), nullptr, PPF_None); + SetField(MapValueProp->Struct, Map.GetValuePtr(Pair), TEXT("Name"), Def.FitMorph); + SetField(MapValueProp->Struct, Map.GetValuePtr(Pair), TEXT("Float"), Def.FitValue); + Map.Rehash(); + InjectedMorphs.Add(Key); + } + } + UE_LOG(LogLifePlay, Log, TEXT("Coiffures : %s ajoutee (index %d, %s, module %s)"), *Key, Index, Def.bMale ? TEXT("M") : TEXT("F"), *Def.Module); + } + UE_LOG(LogLifePlay, Display, TEXT("Coiffures : %d ajoutee(s) depuis les fichiers .lphair, %d ignoree(s), table de %d entree(s)"), Added, Skipped, Table.Num()); +} diff --git a/Source/LifePlay/LPHairstyleSubsystem.h b/Source/LifePlay/LPHairstyleSubsystem.h new file mode 100644 index 0000000..e46bb97 --- /dev/null +++ b/Source/LifePlay/LPHairstyleSubsystem.h @@ -0,0 +1,61 @@ +// Portage UE 5.5 - lot H1 (Docs/Migration/11_Audit_Optimisation.md §16.7). +// +// La liste des coiffures du jeu est la valeur par defaut de GI.HairstyleTable (126 entrees ecrites dans le Blueprint) : +// un mod ne peut pas en ajouter. Ce sous-systeme lit des fichiers texte Modules//**/*.lphair et ajoute leurs +// coiffures en fin de table, ainsi que leur morph d'ajustement dans GI.MorphableHairs. Format (une coiffure par fichier) : +// +// HAIRSTYLE: /Game/Heavy/New5/Hairs/Exemple.Exemple chemin complet d'un SkeletalMesh (squelette Genesis 8) +// SEX: F F ou M (defaut F) +// DAZ: true defaut true ; false = coiffure "legacy", chargee seulement +// si GI.LegacyPercentage > 0 +// FIT_MORPH: Exemple__AllExpand 0.45 facultatif : morph applique par Person.FitMorphableHair +// +// Les modules desactives dans GI.ModEnableds sont ignores ; la table est resynchronisee quand cette liste change. +// Aucun Blueprint n'est modifie : sans fichier .lphair, la table reste celle du Blueprint. Les entrees du Blueprint +// ne sont jamais modifiees ni reordonnees, une coiffure deja presente n'est pas ajoutee une seconde fois. + +#pragma once + +#include "CoreMinimal.h" +#include "Containers/Ticker.h" +#include "Subsystems/GameInstanceSubsystem.h" +#include "LPHairstyleSubsystem.generated.h" + +UCLASS() +class LIFEPLAY_API ULPHairstyleSubsystem : public UGameInstanceSubsystem +{ + GENERATED_BODY() + +public: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + +private: + struct FHairDef + { + FString File; + FString Module; + FString Path; + bool bMale = false; + bool bDaz = true; + FString FitMorph; + FString FitValue; + }; + + bool Tick(float DeltaTime); + /** Releve des fichiers .lphair (une fois : les fichiers de Modules ne changent pas en cours de partie). */ + void ScanFiles(); + /** Retire les entrees ajoutees precedemment puis ajoute celles des modules actifs. */ + void Sync(); + FString EnabledSignature() const; + bool IsModuleEnabled(const FString& Module) const; + + FTSTicker::FDelegateHandle TickHandle; + double NextCheck = 0.0; + FString LastSignature; + TArray Defs; + /** Chemins ajoutes a HairstyleTable par ce sous-systeme, dans l'ordre. */ + TArray Injected; + /** Cles ajoutees a MorphableHairs par ce sous-systeme. */ + TArray InjectedMorphs; +};