diff --git a/Docs/Migration/11_Audit_Optimisation.md b/Docs/Migration/11_Audit_Optimisation.md index 8a0e809..5488c81 100644 --- a/Docs/Migration/11_Audit_Optimisation.md +++ b/Docs/Migration/11_Audit_Optimisation.md @@ -628,6 +628,30 @@ appliquées, suivi des animations. Reste mineur accepté : pointe sombre du bas **Limites connues** : pas de morphs de forme (la coiffure ne suit pas les morphs du visage) ; rigide sur la tête (pas de mouvement des mèches) ; ajustement automatique à contrôler visuellement. +### 16.9 H2 : liste de base des coiffures en fichier de données (2026-10-02) + +La valeur par défaut de `GI.HairstyleTable` (126 entrées) et de `GI.MorphableHairs` (89 entrées) est exportée dans +`Modules/vin_Base/Hairstyles/vin_Base.lphair` (marqué `MODE: BASE`), généré depuis les relevés `LPDumpCDO` du Blueprint. +Le sous-système **reconstruit** la table à partir de ce fichier, dans son ordre, puis ajoute les coiffures des autres +modules. Le Blueprint n'est pas modifié : sans fichier de base, sa table reste la base (comportement H1). + +**Format étendu** (rétrocompatible) : plusieurs coiffures par fichier (chaque `HAIRSTYLE` ouvre une entrée) ; +`HAIRSTYLE: NONE` (option sans cheveux) ; `MORPHABLE: Clé [Morph Valeur]` (entrée brute de `MorphableHairs`) ; +`MODE: BASE` (un seul fichier de base, le premier par ordre alphabétique). + +**Fidélité** : les entrées du fichier de base ne sont pas filtrées (les 35 coiffures « legacy » absentes du projet depuis +4.27 sont reprises, comme dans le Blueprint ; une clé de morph sans coiffure, `LongHair_223410`, aussi). Détails +reproduits : trois options sans cheveux (F, M, et une F marquée Daz) dont le chemin est une chaîne explicitement vide, +différente de la valeur par défaut du champ. + +**Vérification** (option `-LPVerifyHairstyles`, build `LifePlay_Build_5.5_H2`) : table et morphs reconstruits comparés +au Blueprint en texte exporté. Deux écarts trouvés et corrigés (chemin vide des options sans cheveux, puis affectation +directe de la chaîne vide), puis : « table IDENTIQUE au Blueprint (126 entrees), morphs IDENTIQUES (89 entrees) » ; +`Pocolov17` ajoutée ensuite à l'index 126. + +**Usage pour un moddeur** : ajouter une coiffure = un fichier `.lphair` dans son module ; modifier la liste de base +(retirer une coiffure du tirage, changer son sexe, son morph) = éditer `vin_Base.lphair`, sans éditeur Unreal. + ## 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 6aa35cd..373c2b6 100644 --- a/Docs/Migration/Journal_Modifications.md +++ b/Docs/Migration/Journal_Modifications.md @@ -933,3 +933,4 @@ Validation L7c/L7d (2026-10-02, parc, même cadrage) : la nuit reste claire (sol | CHG-086 | 2026-10-02 | O7e | `LPHairFromStaticMeshCommandlet` : ajustement sur le haut du crâne (`-Cranium`, `-Margin`) ; `Pocolov17` et `_Skeleton` régénérés (premier essai dans `O7e\essai1`), `Pocolov17_Strands` : `Thicken` 10 → 1 ; `11_Audit_Optimisation.md` §16.8 | premier essai en jeu (capture utilisateur) : coiffure trop étroite enfoncée dans le crâne, mèches opaques ; échelle 0,74 → 1,19 en largeur ; build `LifePlay_Build_5.5_O7e` reconstruit ; test en jeu en attente | `Tools`/assets : remettre `O7e\essai1` ; `git checkout O7e-apres -- Source/LifePlayEditorTools` | | CHG-087 | 2026-10-02 | O7e | `Pocolov17_Cap` (hors dépôt) : `MapOpacity` = texture blanche du moteur ; `11_Audit_Optimisation.md` §16.8 | second essai en jeu correct (crâne couvert, couleur appliquée, profil bien placé) ; trous au-dessus de l'oreille dus à la lisière découpée de la calotte ; retouche choisie par l'utilisateur ; build `LifePlay_Build_5.5_O7e` reconstruit | remettre `Pocolov17_CapOpacity` dans `MapOpacity` | | CHG-088 | 2026-10-02 | O7e | — | O7e validé en jeu par l'utilisateur (profil sans trou, mouvement suivi) ; défaut mineur accepté : bas de la calotte visible sous le lobe de l'oreille | — | +| CHG-089 | 2026-10-02 | H2 | `Source/LifePlay/LPHairstyleSubsystem.h/.cpp` (mode base, plusieurs coiffures par fichier, `NONE`, `MORPHABLE`, vérification `-LPVerifyHairstyles`) ; nouveau `Content/Modules/vin_Base/Hairstyles/vin_Base.lphair` (126 coiffures, 89 morphs) ; `11_Audit_Optimisation.md` §16.9 | liste de base des coiffures sortie du Blueprint (demande utilisateur) ; vérifié : table et morphs reconstruits identiques au Blueprint ; build `LifePlay_Build_5.5_H2` ; test en jeu en attente | supprimer `vin_Base.lphair` (retour à la table du Blueprint) ; `git checkout H2-avant -- Source/LifePlay` | diff --git a/Source/LifePlay/LPHairstyleSubsystem.cpp b/Source/LifePlay/LPHairstyleSubsystem.cpp index 0dbb505..179edab 100644 --- a/Source/LifePlay/LPHairstyleSubsystem.cpp +++ b/Source/LifePlay/LPHairstyleSubsystem.cpp @@ -8,6 +8,7 @@ #include "HAL/FileManager.h" #include "HAL/PlatformTime.h" #include "LifePlay.h" +#include "Misc/CommandLine.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" #include "UObject/UnrealType.h" @@ -39,6 +40,23 @@ namespace return Prop && Prop->ImportText_InContainer(*Text, Data, nullptr, PPF_None) != nullptr; } + /** Vide un champ texte (chaine vide, nom nul) : ImportText ne sait pas produire une chaine vide. */ + bool ClearField(const UStruct* Struct, void* Data, const TCHAR* AuthoredName) + { + const FProperty* Prop = FindField(Struct, AuthoredName); + if (const FStrProperty* Str = CastField(Prop)) + { + Str->SetPropertyValue_InContainer(Data, FString()); + return true; + } + if (const FNameProperty* Name = CastField(Prop)) + { + Name->SetPropertyValue_InContainer(Data, NAME_None); + return true; + } + return false; + } + FString GetFieldText(const UStruct* Struct, const void* Data, const TCHAR* AuthoredName) { FString Out; @@ -56,13 +74,64 @@ namespace OutStruct = Inner ? Inner->Struct : nullptr; return OutStruct ? Prop : nullptr; } + + FString ExportProperty(const FProperty* Prop, const UObject* GI) + { + FString Out; + Prop->ExportTextItem_Direct(Out, Prop->ContainerPtrToValuePtr(GI), nullptr, const_cast(GI), PPF_None); + return Out; + } + + /** Entrees d'une map "cle=valeur", triees : comparaison independante de l'ordre interne. */ + FString ExportMapSorted(const FMapProperty* MapProp, const UObject* GI) + { + TArray Lines; + FScriptMapHelper Map(MapProp, MapProp->ContainerPtrToValuePtr(GI)); + for (FScriptMapHelper::FIterator It(Map); It; ++It) + { + FString Key, Value; + MapProp->KeyProp->ExportTextItem_Direct(Key, Map.GetKeyPtr(It), nullptr, nullptr, PPF_None); + MapProp->ValueProp->ExportTextItem_Direct(Value, Map.GetValuePtr(It), nullptr, nullptr, PPF_None); + Lines.Add(Key + TEXT("=") + Value); + } + Lines.Sort(); + return FString::Join(Lines, TEXT("\n")); + } + + bool AddMorph(const FMapProperty* MapProp, UObject* GI, const FString& Key, const FString& Morph, const FString& Value) + { + const FStructProperty* ValueProp = CastField(MapProp->ValueProp); + if (!ValueProp) + { + return false; + } + FScriptMapHelper Map(MapProp, MapProp->ContainerPtrToValuePtr(GI)); + for (FScriptMapHelper::FIterator It(Map); It; ++It) + { + FString Existing; + MapProp->KeyProp->ExportTextItem_Direct(Existing, Map.GetKeyPtr(It), nullptr, nullptr, PPF_None); + if (Existing.TrimQuotes().Equals(Key, ESearchCase::IgnoreCase)) + { + return false; + } + } + const int32 Pair = Map.AddDefaultValue_Invalid_NeedsRehash(); + MapProp->KeyProp->ImportText_Direct(*Key, Map.GetKeyPtr(Pair), nullptr, PPF_None); + if (!Morph.IsEmpty()) + { + SetField(ValueProp->Struct, Map.GetValuePtr(Pair), TEXT("Name"), Morph); + SetField(ValueProp->Struct, Map.GetValuePtr(Pair), TEXT("Float"), Value); + } + Map.Rehash(); + return true; + } } void ULPHairstyleSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); ScanFiles(); - if (Defs.Num() == 0) + if (Defs.Num() == 0 && BaseDefs.Num() == 0) { return; } @@ -89,7 +158,7 @@ bool ULPHairstyleSubsystem::Tick(float DeltaTime) if (Signature != LastSignature) { LastSignature = Signature; - UE_LOG(LogLifePlay, Log, TEXT("Coiffures : liste des modules actifs modifiee, resynchronisation")); + UE_LOG(LogLifePlay, Log, TEXT("Coiffures : liste des modules actifs modifiee, reconstruction")); Sync(); } return true; @@ -117,55 +186,93 @@ void ULPHairstyleSubsystem::ScanFiles() 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) + ParseFile(Relative, Module, Text); + } + if (Files.Num() > 0) + { + UE_LOG(LogLifePlay, Display, TEXT("Coiffures : %d fichier(s) .lphair, base %s (%d coiffure(s), %d morph(s)), %d coiffure(s) de modules"), + Files.Num(), BaseFile.IsEmpty() ? TEXT("du Blueprint") : *BaseFile, BaseDefs.Num(), BaseMorphs.Num(), Defs.Num()); + } +} + +void ULPHairstyleSubsystem::ParseFile(const FString& Relative, const FString& Module, const FString& Text) +{ + TArray FileDefs; + TArray FileMorphs; + bool bBase = false; + 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)) { - 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("."))) + Key = Key.TrimStartAndEnd().ToUpper(); + Value = Value.TrimStartAndEnd(); + if (Key == TEXT("MODE")) { - Def.Path += TEXT(".") + FPaths::GetBaseFilename(Def.Path); + bBase = Value.Equals(TEXT("BASE"), ESearchCase::IgnoreCase); + } + else if (Key == TEXT("HAIRSTYLE")) + { + FHairDef& Def = FileDefs.AddDefaulted_GetRef(); + Def.File = Relative; + Def.Module = Module; + Def.bNone = Value.Equals(TEXT("NONE"), ESearchCase::IgnoreCase); + Def.Path = Def.bNone ? FString() : Value; + // Un chemin de paquet sans nom d'objet (/Game/X/Nom) designe l'objet du meme nom. + if (!Def.bNone && !Def.Path.Contains(TEXT("."))) + { + Def.Path += TEXT(".") + FPaths::GetBaseFilename(Def.Path); + } + } + else if (Key == TEXT("MORPHABLE")) + { + TArray Parts; + Value.ParseIntoArrayWS(Parts); + if (Parts.Num() > 0) + { + FileMorphs.Add({ Parts[0], Parts.IsValidIndex(1) ? Parts[1] : FString(), Parts.IsValidIndex(2) ? Parts[2] : FString() }); + } + } + else if (FileDefs.Num() == 0) + { + UE_LOG(LogLifePlay, Warning, TEXT("Coiffures : %s, ligne '%s' avant toute ligne HAIRSTYLE, ignoree"), *Relative, *Line); + } + else if (Key == TEXT("SEX")) + { + FileDefs.Last().bMale = Value.StartsWith(TEXT("M"), ESearchCase::IgnoreCase); + } + else if (Key == TEXT("DAZ")) + { + FileDefs.Last().bDaz = !Value.Equals(TEXT("false"), ESearchCase::IgnoreCase); + } + else if (Key == TEXT("FIT_MORPH")) + { + Value.Split(TEXT(" "), &FileDefs.Last().FitMorph, &FileDefs.Last().FitValue); + FileDefs.Last().FitValue.TrimStartAndEndInline(); } - Defs.Add(MoveTemp(Def)); } - if (Defs.Num() > 0) + if (bBase) { - UE_LOG(LogLifePlay, Display, TEXT("Coiffures : %d fichier(s) .lphair trouve(s) dans Modules"), Defs.Num()); + if (!BaseFile.IsEmpty()) + { + UE_LOG(LogLifePlay, Warning, TEXT("Coiffures : %s ignore, la base est deja %s"), *Relative, *BaseFile); + return; + } + BaseFile = Relative; + BaseDefs = MoveTemp(FileDefs); + BaseMorphs = MoveTemp(FileMorphs); + return; } + if (FileDefs.Num() == 0) + { + UE_LOG(LogLifePlay, Warning, TEXT("Coiffures : %s sans ligne HAIRSTYLE, ignore"), *Relative); + } + Defs.Append(MoveTemp(FileDefs)); } FString ULPHairstyleSubsystem::EnabledSignature() const @@ -212,51 +319,75 @@ void ULPHairstyleSubsystem::Sync() const UScriptStruct* Struct = nullptr; const FArrayProperty* TableProp = StructArray(GI, HairstyleTableName, Struct); const FMapProperty* MapProp = GI ? CastField(GI->GetClass()->FindPropertyByName(MorphableHairsName)) : nullptr; - if (!TableProp) + if (!TableProp || !MapProp) { - UE_LOG(LogLifePlay, Error, TEXT("Coiffures : GI.HairstyleTable introuvable sur %s, aucune coiffure ajoutee"), *GetNameSafe(GI)); + UE_LOG(LogLifePlay, Error, TEXT("Coiffures : GI.HairstyleTable ou GI.MorphableHairs introuvable sur %s, table laissee telle quelle"), *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 (!bCaptured) { - if (Injected.Last() == GetFieldText(Struct, Table.GetRawPtr(i), TEXT("SkeletalMeshPath"))) - { - Table.RemoveValues(i, 1); - Injected.Pop(); - } + BlueprintTableText = ExportProperty(TableProp, GI); + BlueprintMorphsText = ExportProperty(MapProp, GI); + bCaptured = true; } - Injected.Reset(); - const FStructProperty* MapValueProp = MapProp ? CastField(MapProp->ValueProp) : nullptr; - FScriptMapHelper Map(MapProp, MapProp ? MapProp->ContainerPtrToValuePtr(GI) : nullptr); - if (MapProp) + FScriptArrayHelper Table(TableProp, TableProp->ContainerPtrToValuePtr(GI)); + FScriptMapHelper Map(MapProp, MapProp->ContainerPtrToValuePtr(GI)); + + // 1. Base : fichier MODE: BASE reconstruit dans son ordre, sinon valeurs du Blueprint. + if (BaseDefs.Num() > 0) { - for (const FString& Key : InjectedMorphs) + const FString BlueprintMorphsSorted = ExportMapSorted(MapProp, GI); + Table.EmptyValues(); + Map.EmptyValues(); + int32 BadFields = 0; + for (const FHairDef& Def : BaseDefs) { - for (FScriptMapHelper::FIterator It(Map); It; ++It) + void* Entry = Table.GetRawPtr(Table.AddValue()); + // Entree "sans cheveux" (NONE) : chemin vide ; sexe et indicateur Daz repris tels quels (le Blueprint en a trois). + if (!(SetField(Struct, Entry, TEXT("Bool"), Def.bMale ? TEXT("True") : TEXT("False")) + && SetField(Struct, Entry, TEXT("Daz"), Def.bDaz ? TEXT("True") : TEXT("False")) + // Le Blueprint stocke un chemin explicitement vide (""), distinct de la valeur par defaut du champ. + && (Def.bNone ? ClearField(Struct, Entry, TEXT("SkeletalMeshPath")) : SetField(Struct, Entry, TEXT("SkeletalMeshPath"), Def.Path)))) { - FString Existing; - MapProp->KeyProp->ExportTextItem_Direct(Existing, Map.GetKeyPtr(It), nullptr, nullptr, PPF_None); - if (Existing.TrimQuotes() == Key) - { - Map.RemoveAt(It.GetInternalIndex()); - break; - } + ++BadFields; + } + } + for (const FMorphDef& Morph : BaseMorphs) + { + AddMorph(MapProp, GI, Morph.Key, Morph.Morph, Morph.Value); + } + if (BadFields > 0) + { + UE_LOG(LogLifePlay, Error, TEXT("Coiffures : %d entree(s) de base incompletes (structure de HairstyleTable inattendue)"), BadFields); + } + static const bool bVerify = FParse::Param(FCommandLine::Get(), TEXT("LPVerifyHairstyles")); + if (bVerify) + { + const bool bSameTable = ExportProperty(TableProp, GI) == BlueprintTableText; + const bool bSameMorphs = ExportMapSorted(MapProp, GI) == BlueprintMorphsSorted; + UE_LOG(LogLifePlay, Display, TEXT("Coiffures, verification de la base %s : table %s (%d entrees), morphs %s (%d entrees)"), *BaseFile, + bSameTable ? TEXT("IDENTIQUE au Blueprint") : TEXT("DIFFERENTE du Blueprint"), Table.Num(), + bSameMorphs ? TEXT("IDENTIQUES") : TEXT("DIFFERENTS"), Map.Num()); + if (!bSameTable) + { + UE_LOG(LogLifePlay, Warning, TEXT("Coiffures : Blueprint = %s"), *BlueprintTableText.Left(2000)); + UE_LOG(LogLifePlay, Warning, TEXT("Coiffures : fichier = %s"), *ExportProperty(TableProp, GI).Left(2000)); } } } - InjectedMorphs.Reset(); + else + { + TableProp->ImportText_Direct(*BlueprintTableText, TableProp->ContainerPtrToValuePtr(GI), GI, PPF_None); + MapProp->ImportText_Direct(*BlueprintMorphsText, MapProp->ContainerPtrToValuePtr(GI), GI, PPF_None); + } + const int32 BaseCount = Table.Num(); - // 2. Chemins deja presents (Blueprint). + // 2. Coiffures des modules actifs, en fin de table. 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) @@ -266,9 +397,9 @@ void ULPHairstyleSubsystem::Sync() ++Skipped; continue; } - if (Known.Contains(Def.Path)) + if (Def.bNone || Known.Contains(Def.Path)) { - UE_LOG(LogLifePlay, Log, TEXT("Coiffures : %s deja dans la table (%s)"), *Def.Path, *Def.File); + UE_LOG(LogLifePlay, Log, TEXT("Coiffures : %s deja dans la table ou vide (%s)"), *Def.Path, *Def.File); ++Skipped; continue; } @@ -281,10 +412,9 @@ void ULPHairstyleSubsystem::Sync() } const int32 Index = Table.AddValue(); void* Entry = Table.GetRawPtr(Index); - const bool bOk = SetField(Struct, Entry, TEXT("SkeletalMeshPath"), Def.Path) + if (!(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) + && SetField(Struct, Entry, TEXT("Daz"), Def.bDaz ? TEXT("True") : TEXT("False")))) { Table.RemoveValues(Index, 1); UE_LOG(LogLifePlay, Error, TEXT("Coiffures : structure de HairstyleTable inattendue, %s non ajoute"), *Def.Path); @@ -292,31 +422,15 @@ void ULPHairstyleSubsystem::Sync() 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) + const FString Name = FPaths::GetExtension(Def.Path); + if (!Def.FitMorph.IsEmpty()) { - 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); - } + AddMorph(MapProp, GI, Name, Def.FitMorph, Def.FitValue); } - 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, Log, TEXT("Coiffures : %s ajoutee (index %d, %s, module %s)"), *Name, 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()); + UE_LOG(LogLifePlay, Display, TEXT("Coiffures : base %d entree(s) (%s), %d ajoutee(s) par les modules, %d ignoree(s), table de %d entree(s), %d morph(s)"), + BaseCount, BaseFile.IsEmpty() ? TEXT("Blueprint") : *BaseFile, Added, Skipped, Table.Num(), Map.Num()); } diff --git a/Source/LifePlay/LPHairstyleSubsystem.h b/Source/LifePlay/LPHairstyleSubsystem.h index e46bb97..34f14dd 100644 --- a/Source/LifePlay/LPHairstyleSubsystem.h +++ b/Source/LifePlay/LPHairstyleSubsystem.h @@ -1,18 +1,26 @@ -// Portage UE 5.5 - lot H1 (Docs/Migration/11_Audit_Optimisation.md §16.7). +// Portage UE 5.5 - lots H1 et H2 (Docs/Migration/11_Audit_Optimisation.md §16.7 et §16.9). // -// 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) : +// La liste des coiffures du jeu est la valeur par defaut de GI.HairstyleTable (126 entrees ecrites dans le Blueprint), +// avec GI.MorphableHairs (morph d'ajustement par coiffure) : un mod ne pouvait pas y toucher. Ce sous-systeme lit les +// fichiers texte Modules//**/*.lphair. Un fichier peut decrire plusieurs coiffures : chaque ligne HAIRSTYLE +// ouvre une entree, les lignes suivantes la completent. // -// HAIRSTYLE: /Game/Heavy/New5/Hairs/Exemple.Exemple chemin complet d'un SkeletalMesh (squelette Genesis 8) +// HAIRSTYLE: /Game/Heavy/New5/Hairs/Exemple.Exemple chemin complet d'un SkeletalMesh (squelette Genesis 8), +// ou NONE (option "sans cheveux") // 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 +// MORPHABLE: Cle [Morph Valeur] entree brute de GI.MorphableHairs (fichier de base) +// MODE: BASE fichier de base (H2) : la table est reconstruite a partir de +// ce fichier, dans son ordre, avant l'ajout des autres modules // -// 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. +// Sans fichier de base, les coiffures sont ajoutees en fin de la table du Blueprint (H1). Les coiffures des modules +// sont controlees (SkeletalMesh present, pas de doublon) ; celles du fichier de base sont reprises telles quelles, +// comme dans le Blueprint (35 coiffures "legacy" absentes du projet depuis 4.27 y figurent). +// Les modules desactives dans GI.ModEnableds sont ignores ; la table est reconstruite quand cette liste change. +// Option -LPVerifyHairstyles : la table et les morphs construits depuis le fichier de base sont compares a ceux du +// Blueprint (texte exporte), avant l'ajout des modules. #pragma once @@ -36,16 +44,25 @@ private: FString File; FString Module; FString Path; + bool bNone = false; bool bMale = false; bool bDaz = true; FString FitMorph; FString FitValue; }; + struct FMorphDef + { + FString Key; + FString Morph; + FString Value; + }; + 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 ParseFile(const FString& Relative, const FString& Module, const FString& Text); + /** Reconstruit la table : base (fichier MODE: BASE ou Blueprint) puis coiffures des modules actifs. */ void Sync(); FString EnabledSignature() const; bool IsModuleEnabled(const FString& Module) const; @@ -53,9 +70,14 @@ private: FTSTicker::FDelegateHandle TickHandle; double NextCheck = 0.0; FString LastSignature; + /** Coiffures et morphs du fichier de base, dans l'ordre. */ + FString BaseFile; + TArray BaseDefs; + TArray BaseMorphs; + /** Coiffures des autres fichiers. */ 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; + /** Table et morphs du Blueprint, captures au premier passage (texte exporte, pour revenir a la base sans fichier). */ + FString BlueprintTableText; + FString BlueprintMorphsText; + bool bCaptured = false; };