DC3 : atelier de mods hors du jeu, dans l'addon LifePlay-Sandbox (CHG-236)

Retrait de BuildMod, NewMod, EditMod, ModSource, HairPipeline et leurs .py, Tools/anim,
Tools/hairs, Tools/mods, ModSources, AnimSources, Config/LPSandbox.ini, lanceurs sandbox et
guide des moddeurs (57 fichiers suivis ; contenu disque deplace vers la sauvegarde DC3).
References et docs mises a jour. Pin addon 79f0205. Validation : DC3-VAL PASS (cuisson
lpr_anim_freeanims depuis l'addon identique au mod publie, tournee identique a SB2-REF).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
edujiro5972 2026-10-07 19:53:13 +02:00
parent 2cf416cb2d
commit e46093c81c
77 changed files with 316 additions and 9513 deletions

6
.gitignore vendored
View file

@ -45,9 +45,9 @@ Plugins/McpAutomationBridge/
# des mods (comme Content/Heavy : livres par l'archive des sources).
/Releases/
/Mods/
/ModSources/*/Saved/
/ModSources/*/Intermediate/
/ModSources/*/Content/
# Lot DC3 : sources de mods et atelier de mods dans le depot addon LifePlay-Sandbox
/ModSources/
/AnimSources/
# Journaux et dumps de debug des lots (restent en local ; .md et .csv restent suivis)
/Docs/Migration/etat/**/*.txt

View file

@ -1,187 +0,0 @@
{
"pack": "freeanims",
"prefixe": "fap",
"source": "Free Animations Pack (SoerGame, Fab ; mannequin UE5)",
"auteur": "SoerGame",
"licence": "CC BY 4.0",
"url": "https://www.fab.com/listings/9e93d929-36e8-45f9-b925-325db1b9385d",
"url_auteur": "https://www.fab.com/sellers/SoerGame",
"fichiers": [
"Content/FreeAnimationsPack (assets Unreal)"
],
"credit": "Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay",
"exclus": [],
"chaine": {
"methode": "auto",
"profil_attendu": "ue5_mannequin",
"source": {
"type": "uasset",
"clips": "/Game/FreeAnimationsPack/Animations",
"maillage": "/Game/FreeAnimationsPack/Demo/Characters/Mannequins/Meshes/SKM_Manny.SKM_Manny",
"clip_reference": "AS_Rest"
},
"sortie": {
"clips": "/Game/AnimPacks/FreeAnimationsPack",
"rigs": "/Game/Retarget_ue5"
},
"corps": [
{
"nom": "Bridget",
"maillage": "/Game/Heavy/Daz/Bridget/bridget_gp.bridget_gp",
"prefixe": "B_",
"gabarit": "/Game/Heavy/Daz/Anims/Idle_F_Daz"
},
{
"nom": "Michael",
"maillage": "/Game/Heavy/Daz/Michael/michael.michael",
"prefixe": "M_",
"gabarit": "/Game/Heavy/Daz/Anims/idle_Daz"
}
],
"mod": "lpr_anim_freeanims",
"travail": "Saved/AnimPipeline/freeanims",
"sandbox": "AnimPipeline/freeanims/sandbox"
},
"animations": {
"fap_as_airkiss": {
"clip": "AS_AirKiss",
"nom": {
"fr": "Baiser envoyé",
"en": "Blow a kiss"
},
"categorie": "interaction",
"accessoire": "",
"style": "détendu",
"usage": {
"fr": "Geste ponctuel (probablement joué une fois) d'envoi de baiser, joué sur place ; utile pour une scène de flirt ou d'adieu.",
"en": "One-shot gesture (probably played once) of blowing a kiss, played in place; useful for a flirting or farewell scene."
}
},
"fap_as_attackinlevitation2": {
"clip": "AS_AttackInLevitation2",
"nom": {
"fr": "Attaque en lévitation",
"en": "Levitation attack"
},
"categorie": "magie",
"accessoire": "",
"style": "agressif",
"usage": {
"fr": "Attaque lancée en lévitation, probablement jouée une fois, sur place ; pour un sort ou une attaque surnaturelle.",
"en": "Attack performed while levitating, probably played once, in place; for a spell or supernatural attack."
}
},
"fap_as_chilldriving": {
"clip": "AS_ChillDriving",
"nom": {
"fr": "Conduite détendue",
"en": "Relaxed driving"
},
"categorie": "conduite",
"accessoire": "volant",
"style": "détendu",
"usage": {
"fr": "Pose de conduite détendue, probablement en boucle, jouée sur place ; à utiliser avec un véhicule ou un volant.",
"en": "Relaxed driving pose, probably looping, played in place; to use with a vehicle or steering wheel."
}
},
"fap_as_combohands": {
"clip": "AS_ComboHands",
"nom": {
"fr": "Enchaînement de poings",
"en": "Punch combo"
},
"categorie": "combat",
"accessoire": "",
"style": "agressif",
"usage": {
"fr": "Enchaînement de coups de poing, probablement joué une fois, sur place ; pour une bagarre ou un entraînement.",
"en": "Sequence of punches, probably played once, in place; for a fight or training scene."
}
},
"fap_as_eatingonparty": {
"clip": "AS_EatingOnParty",
"nom": {
"fr": "Manger en soirée",
"en": "Eating at party"
},
"categorie": "interaction",
"accessoire": "",
"style": "détendu",
"usage": {
"fr": "Personnage mangeant debout lors d'une soirée, probablement en boucle, joué sur place ; pour un buffet ou une fête.",
"en": "Character eating while standing at a party, probably looping, played in place; for a buffet or party scene."
}
},
"fap_as_flyingup": {
"clip": "AS_FlyingUp",
"nom": {
"fr": "Envol",
"en": "Take off"
},
"categorie": "magie",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Décollage vers le haut, probablement joué une fois, sur place ; pour un envol ou une lévitation.",
"en": "Upward take-off, probably played once, in place; for flying off or levitating."
}
},
"fap_as_goforward": {
"clip": "AS_GoForward",
"nom": {
"fr": "Marche avant",
"en": "Walk forward"
},
"categorie": "locomotion",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Cycle de marche vers l'avant, probablement en boucle, joué sur place ; à combiner avec un déplacement du personnage.",
"en": "Forward walk cycle, probably looping, played in place; combine with a character movement."
}
},
"fap_as_rest": {
"clip": "AS_Rest",
"nom": {
"fr": "Repos",
"en": "Rest"
},
"categorie": "attente",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Posture de repos debout, probablement en boucle, jouée sur place ; pose d'attente par défaut.",
"en": "Standing rest posture, probably looping, played in place; default waiting pose."
}
},
"fap_as_rightlegkick2": {
"clip": "AS_RightLegKick2",
"nom": {
"fr": "Coup de pied droit",
"en": "Right leg kick"
},
"categorie": "combat",
"accessoire": "",
"style": "agressif",
"usage": {
"fr": "Coup de pied de la jambe droite, probablement joué une fois, sur place ; pour un combat ou un entraînement.",
"en": "Right leg kick, probably played once, in place; for a fight or training scene."
}
},
"fap_as_talk": {
"clip": "AS_Talk",
"nom": {
"fr": "Parler",
"en": "Talk"
},
"categorie": "conversation",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Personnage qui parle debout, probablement en boucle, joué sur place ; pour un dialogue ou une conversation.",
"en": "Character talking while standing, probably looping, played in place; for a dialogue or conversation."
}
}
}
}

View file

@ -1,640 +0,0 @@
{
"pack": "quaternius",
"prefixe": "qua",
"source": "Quaternius Universal Animation Library (Standard)",
"auteur": "Quaternius",
"licence": "CC0 1.0",
"url": "https://quaternius.com",
"fichiers": [
"D:/Game/Universal Animation Library[Standard]/Unreal-Godot/UAL1_Standard.glb"
],
"credit": "Animations : Universal Animation Library (Standard) par Quaternius, licence CC0 1.0, https://quaternius.com",
"exclus": [
"A_TPose"
],
"chaine": {
"methode": "auto",
"profil_attendu": "ue4_mannequin",
"source": {
"type": "glb",
"fichier": "D:/Game/Universal Animation Library[Standard]/Unreal-Godot/UAL1_Standard.glb",
"import": "Tools/ImportQuaternius.py",
"clips": "/Game/Quaternius/UAL",
"maillage": "/Game/Quaternius/UAL/UAL1_Standard.UAL1_Standard",
"clip_reference": "A_TPose"
},
"sortie": {
"clips": "/Game/Quaternius/UAL",
"rigs": "/Game/Quaternius/Retarget"
},
"corps": [
{
"nom": "Bridget",
"maillage": "/Game/Heavy/Daz/Bridget/bridget_gp.bridget_gp",
"prefixe": "B_",
"gabarit": "/Game/Heavy/Daz/Anims/Idle_F_Daz"
},
{
"nom": "Michael",
"maillage": "/Game/Heavy/Daz/Michael/michael.michael",
"prefixe": "M_",
"gabarit": "/Game/Heavy/Daz/Anims/idle_Daz"
}
],
"mod": "lpr_anim_quaternius",
"travail": "Saved/AnimPipeline/quaternius",
"sandbox": "Measurement"
},
"animations": {
"qua_crouch_fwd_loop": {
"clip": "Crouch_Fwd_Loop",
"nom": {
"fr": "Marche accroupie",
"en": "Crouch walk"
},
"categorie": "locomotion",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Boucle de marche accroupie sur place ; à combiner avec un déplacement pour une scène furtive.",
"en": "Looping crouched walk in place; combine with a character movement for a sneaking scene."
}
},
"qua_crouch_idle_loop": {
"clip": "Crouch_Idle_Loop",
"nom": {
"fr": "Attente accroupie",
"en": "Crouch idle"
},
"categorie": "attente",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Boucle d'attente accroupie, pour se cacher ou observer discrètement.",
"en": "Looping crouched idle, for hiding or watching discreetly."
}
},
"qua_dance_loop": {
"clip": "Dance_Loop",
"nom": {
"fr": "Danse",
"en": "Dance"
},
"categorie": "danse",
"accessoire": "",
"style": "détendu",
"usage": {
"fr": "Boucle de danse libre, pour une scène de fête ou de club.",
"en": "Looping free dance, for a party or club scene."
}
},
"qua_death01": {
"clip": "Death01",
"nom": {
"fr": "Mort",
"en": "Death"
},
"categorie": "chute",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Joué une fois : le personnage s'effondre et reste au sol ; à utiliser pour une mort ou un évanouissement.",
"en": "Played once: the character collapses and stays down; use for a death or a faint."
}
},
"qua_driving_loop": {
"clip": "Driving_Loop",
"nom": {
"fr": "Conduite",
"en": "Driving"
},
"categorie": "conduite",
"accessoire": "volant",
"style": "neutre",
"usage": {
"fr": "Boucle de conduite, mains sur le volant ; pour un personnage assis dans un véhicule.",
"en": "Looping driving pose, hands on the wheel; for a character seated in a vehicle."
}
},
"qua_fixing_kneeling": {
"clip": "Fixing_Kneeling",
"nom": {
"fr": "Réparer à genoux",
"en": "Fix kneeling"
},
"categorie": "interaction",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Boucle de bricolage à genoux, pour une réparation ou un travail au sol.",
"en": "Looping kneeling repair work, for fixing something or working at ground level."
}
},
"qua_hit_chest": {
"clip": "Hit_Chest",
"nom": {
"fr": "Coup à la poitrine",
"en": "Hit in chest"
},
"categorie": "combat",
"accessoire": "",
"style": "agressif",
"usage": {
"fr": "Joué une fois : réaction à un coup reçu au torse ; à placer après une attaque.",
"en": "Played once: reaction to a blow to the chest; place it after an attack."
}
},
"qua_hit_head": {
"clip": "Hit_Head",
"nom": {
"fr": "Coup à la tête",
"en": "Hit in head"
},
"categorie": "combat",
"accessoire": "",
"style": "agressif",
"usage": {
"fr": "Joué une fois : réaction à un coup reçu à la tête ; à placer après une attaque.",
"en": "Played once: reaction to a blow to the head; place it after an attack."
}
},
"qua_idle_loop": {
"clip": "Idle_Loop",
"nom": {
"fr": "Attente",
"en": "Idle"
},
"categorie": "attente",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Boucle d'attente debout par défaut, pour un personnage qui patiente.",
"en": "Default standing idle loop, for a waiting character."
}
},
"qua_idle_talking_loop": {
"clip": "Idle_Talking_Loop",
"nom": {
"fr": "Attente en parlant",
"en": "Idle talking"
},
"categorie": "conversation",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Boucle debout avec gestes de la main, pour un personnage qui parle dans un dialogue.",
"en": "Standing loop with hand gestures, for a character speaking in a dialogue."
}
},
"qua_idle_torch_loop": {
"clip": "Idle_Torch_Loop",
"nom": {
"fr": "Attente avec torche",
"en": "Idle with torch"
},
"categorie": "attente",
"accessoire": "torche",
"style": "neutre",
"usage": {
"fr": "Boucle d'attente debout en tenant une torche ; pour une scène de nuit ou d'exploration.",
"en": "Standing idle loop holding a torch; for a night or exploration scene."
}
},
"qua_interact": {
"clip": "Interact",
"nom": {
"fr": "Interagir",
"en": "Interact"
},
"categorie": "interaction",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Joué une fois : geste pour actionner un objet (bouton, porte, levier).",
"en": "Played once: gesture to operate an object (button, door, lever)."
}
},
"qua_jog_fwd_loop": {
"clip": "Jog_Fwd_Loop",
"nom": {
"fr": "Jogging",
"en": "Jog"
},
"categorie": "locomotion",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Boucle de course légère sur place ; à combiner avec un déplacement du personnage.",
"en": "Looping light jog in place; combine with a character movement."
}
},
"qua_jump_land": {
"clip": "Jump_Land",
"nom": {
"fr": "Réception de saut",
"en": "Jump landing"
},
"categorie": "chute",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Joué une fois : réception au sol ; dernière phase d'un saut (après la boucle en l'air).",
"en": "Played once: landing on the ground; last phase of a jump (after the airborne loop)."
}
},
"qua_jump_loop": {
"clip": "Jump_Loop",
"nom": {
"fr": "Saut en l'air",
"en": "Jump airborne"
},
"categorie": "chute",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Boucle de la phase en l'air d'un saut, entre le départ et la réception.",
"en": "Looping airborne phase of a jump, between the start and the landing."
}
},
"qua_jump_start": {
"clip": "Jump_Start",
"nom": {
"fr": "Départ de saut",
"en": "Jump start"
},
"categorie": "chute",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Joué une fois : impulsion au départ d'un saut ; première phase avant la boucle en l'air.",
"en": "Played once: take-off impulse; first phase before the airborne loop."
}
},
"qua_pickup_table": {
"clip": "PickUp_Table",
"nom": {
"fr": "Ramasser sur une table",
"en": "Pick up from table"
},
"categorie": "interaction",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Joué une fois : le personnage prend un objet posé sur une table.",
"en": "Played once: the character picks up an object lying on a table."
}
},
"qua_pistol_aim_down": {
"clip": "Pistol_Aim_Down",
"nom": {
"fr": "Viser vers le bas",
"en": "Aim down"
},
"categorie": "combat",
"accessoire": "pistolet",
"style": "agressif",
"usage": {
"fr": "Pose de visée pistolet vers le bas ; à enchaîner avec les autres visées ou le tir.",
"en": "Pistol aiming pose pointing down; chain with the other aims or the shot."
}
},
"qua_pistol_aim_neutral": {
"clip": "Pistol_Aim_Neutral",
"nom": {
"fr": "Viser droit",
"en": "Aim straight"
},
"categorie": "combat",
"accessoire": "pistolet",
"style": "agressif",
"usage": {
"fr": "Pose de visée pistolet à l'horizontale ; pose de base avant un tir.",
"en": "Pistol aiming pose at level; base pose before a shot."
}
},
"qua_pistol_aim_up": {
"clip": "Pistol_Aim_Up",
"nom": {
"fr": "Viser vers le haut",
"en": "Aim up"
},
"categorie": "combat",
"accessoire": "pistolet",
"style": "agressif",
"usage": {
"fr": "Pose de visée pistolet vers le haut ; à enchaîner avec les autres visées ou le tir.",
"en": "Pistol aiming pose pointing up; chain with the other aims or the shot."
}
},
"qua_pistol_idle_loop": {
"clip": "Pistol_Idle_Loop",
"nom": {
"fr": "Attente au pistolet",
"en": "Pistol idle"
},
"categorie": "combat",
"accessoire": "pistolet",
"style": "agressif",
"usage": {
"fr": "Boucle d'attente debout, pistolet en main ; pour un personnage armé qui guette.",
"en": "Standing idle loop with a pistol in hand; for an armed character on guard."
}
},
"qua_pistol_reload": {
"clip": "Pistol_Reload",
"nom": {
"fr": "Recharger le pistolet",
"en": "Pistol reload"
},
"categorie": "combat",
"accessoire": "pistolet",
"style": "agressif",
"usage": {
"fr": "Joué une fois : rechargement du pistolet ; à placer entre deux tirs.",
"en": "Played once: pistol reload; place it between two shots."
}
},
"qua_pistol_shoot": {
"clip": "Pistol_Shoot",
"nom": {
"fr": "Tir au pistolet",
"en": "Pistol shot"
},
"categorie": "combat",
"accessoire": "pistolet",
"style": "agressif",
"usage": {
"fr": "Joué une fois : un tir de pistolet avec recul.",
"en": "Played once: one pistol shot with recoil."
}
},
"qua_punch_cross": {
"clip": "Punch_Cross",
"nom": {
"fr": "Direct",
"en": "Cross punch"
},
"categorie": "combat",
"accessoire": "",
"style": "agressif",
"usage": {
"fr": "Joué une fois : coup de poing direct ; pour une bagarre.",
"en": "Played once: cross punch; for a fight."
}
},
"qua_punch_jab": {
"clip": "Punch_Jab",
"nom": {
"fr": "Jab",
"en": "Jab"
},
"categorie": "combat",
"accessoire": "",
"style": "agressif",
"usage": {
"fr": "Joué une fois : coup de poing rapide ; pour une bagarre.",
"en": "Played once: quick jab punch; for a fight."
}
},
"qua_push_loop": {
"clip": "Push_Loop",
"nom": {
"fr": "Pousser",
"en": "Push"
},
"categorie": "interaction",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Boucle de poussée à deux mains sur place ; pour pousser un meuble ou une porte lourde.",
"en": "Looping two-handed push in place; for pushing furniture or a heavy door."
}
},
"qua_roll": {
"clip": "Roll",
"nom": {
"fr": "Roulade",
"en": "Roll"
},
"categorie": "sport",
"accessoire": "",
"style": "agressif",
"usage": {
"fr": "Joué une fois : roulade au sol ; pour esquiver ou plonger.",
"en": "Played once: ground roll; for dodging or diving."
}
},
"qua_sitting_enter": {
"clip": "Sitting_Enter",
"nom": {
"fr": "S'asseoir",
"en": "Sit down"
},
"categorie": "assis",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Joué une fois : entrée, le personnage passe de debout à assis ; à suivre d'une boucle assise.",
"en": "Played once: entry, the character goes from standing to seated; follow with a seated loop."
}
},
"qua_sitting_exit": {
"clip": "Sitting_Exit",
"nom": {
"fr": "Se lever",
"en": "Stand up"
},
"categorie": "assis",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Joué une fois : sortie, le personnage passe d'assis à debout ; à placer après une boucle assise.",
"en": "Played once: exit, the character goes from seated to standing; place after a seated loop."
}
},
"qua_sitting_idle_loop": {
"clip": "Sitting_Idle_Loop",
"nom": {
"fr": "Assis, attente",
"en": "Seated idle"
},
"categorie": "assis",
"accessoire": "",
"style": "détendu",
"usage": {
"fr": "Boucle d'attente assise, après « s'asseoir » ; pour un personnage installé sur une chaise.",
"en": "Seated idle loop, after the sit-down clip; for a character settled on a chair."
}
},
"qua_sitting_talking_loop": {
"clip": "Sitting_Talking_Loop",
"nom": {
"fr": "Assis, en parlant",
"en": "Seated talking"
},
"categorie": "assis",
"accessoire": "",
"style": "détendu",
"usage": {
"fr": "Boucle assise avec gestes de la main, pour un dialogue entre personnages assis.",
"en": "Seated loop with hand gestures, for a dialogue between seated characters."
}
},
"qua_spell_simple_enter": {
"clip": "Spell_Simple_Enter",
"nom": {
"fr": "Début d'incantation",
"en": "Spell start"
},
"categorie": "magie",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Joué une fois : mise en position pour lancer un sort ; à suivre de la boucle d'incantation.",
"en": "Played once: stance to begin casting a spell; follow with the spell idle loop."
}
},
"qua_spell_simple_exit": {
"clip": "Spell_Simple_Exit",
"nom": {
"fr": "Fin d'incantation",
"en": "Spell end"
},
"categorie": "magie",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Joué une fois : retour à la posture normale après un sort.",
"en": "Played once: return to normal stance after a spell."
}
},
"qua_spell_simple_idle_loop": {
"clip": "Spell_Simple_Idle_Loop",
"nom": {
"fr": "Incantation",
"en": "Spell idle"
},
"categorie": "magie",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Boucle de concentration, mains levées, entre l'entrée et le lancer du sort.",
"en": "Looping concentration pose, hands raised, between the entry and the cast."
}
},
"qua_spell_simple_shoot": {
"clip": "Spell_Simple_Shoot",
"nom": {
"fr": "Lancer un sort",
"en": "Cast spell"
},
"categorie": "magie",
"accessoire": "",
"style": "agressif",
"usage": {
"fr": "Joué une fois : le geste qui lance le sort ; à placer après la boucle d'incantation.",
"en": "Played once: the gesture that casts the spell; place after the spell idle loop."
}
},
"qua_sprint_loop": {
"clip": "Sprint_Loop",
"nom": {
"fr": "Sprint",
"en": "Sprint"
},
"categorie": "locomotion",
"accessoire": "",
"style": "agressif",
"usage": {
"fr": "Boucle de course rapide sur place ; à combiner avec un déplacement pour une fuite ou une poursuite.",
"en": "Looping fast run in place; combine with a movement for an escape or chase."
}
},
"qua_swim_fwd_loop": {
"clip": "Swim_Fwd_Loop",
"nom": {
"fr": "Nage",
"en": "Swim"
},
"categorie": "nage",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Boucle de nage en avant ; à combiner avec un déplacement dans l'eau.",
"en": "Looping forward swim; combine with a movement in water."
},
"posture": "dans l'eau"
},
"qua_swim_idle_loop": {
"clip": "Swim_Idle_Loop",
"nom": {
"fr": "Surplace dans l'eau",
"en": "Treading water"
},
"categorie": "nage",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Boucle de flottaison sur place dans l'eau.",
"en": "Looping treading water in place."
},
"posture": "dans l'eau"
},
"qua_sword_attack": {
"clip": "Sword_Attack",
"nom": {
"fr": "Attaque à l'épée",
"en": "Sword attack"
},
"categorie": "combat",
"accessoire": "épée",
"style": "agressif",
"usage": {
"fr": "Joué une fois : un coup d'épée ; à enchaîner après l'attente à l'épée.",
"en": "Played once: a sword strike; chain after the sword idle."
}
},
"qua_sword_idle": {
"clip": "Sword_Idle",
"nom": {
"fr": "Attente à l'épée",
"en": "Sword idle"
},
"categorie": "combat",
"accessoire": "épée",
"style": "agressif",
"usage": {
"fr": "Boucle d'attente debout, épée en main, en garde.",
"en": "Standing idle loop, sword in hand, on guard."
}
},
"qua_walk_formal_loop": {
"clip": "Walk_Formal_Loop",
"nom": {
"fr": "Marche formelle",
"en": "Formal walk"
},
"categorie": "locomotion",
"accessoire": "",
"style": "formel",
"usage": {
"fr": "Boucle de marche tenue et digne sur place ; à combiner avec un déplacement (cérémonie, travail).",
"en": "Looping upright, dignified walk in place; combine with a movement (ceremony, office)."
}
},
"qua_walk_loop": {
"clip": "Walk_Loop",
"nom": {
"fr": "Marche",
"en": "Walk"
},
"categorie": "locomotion",
"accessoire": "",
"style": "neutre",
"usage": {
"fr": "Boucle de marche sur place ; à combiner avec un déplacement du personnage.",
"en": "Looping walk in place; combine with a character movement."
}
}
}
}

View file

@ -1,42 +0,0 @@
; Config/LPSandbox.ini
; Contrat S1-T1 — chemins et options du bac a sable LPPairSandbox.
; Toute cle absente => valeur par defaut du code (identique a celle-ci).
; Chemins "/Game/..." : format LoadObject (/Package/Nom.Nom ou /Package/BP.BP_C).
; Chemins relatifs (sans "/") : relatifs a Content/ sauf [Sandbox.Output] (relatif a Saved/).
[Sandbox.Mode]
; Mode initial du menu : Pair | Interaction | Mesure
; (Saved/sandbox_mode.txt, s'il existe, reste prioritaire et est consomme.)
Mode=Pair
[Sandbox.Assets]
PersonBP=/Game/BPs/Person.Person_C
BridgetMesh=/Game/Heavy/Daz/Bridget/bridget_gp.bridget_gp
MichaelMesh=/Game/Heavy/Daz/Michael/michael.michael
QuaterniusMannequinMesh=/Game/Quaternius/UAL/UAL1_Standard.UAL1_Standard
DazIdleFemale=/Game/Heavy/Daz/Anims/Idle_F_Daz.Idle_F_Daz
DazIdleMale=/Game/Heavy/Daz/Anims/idle_Daz.idle_Daz
FurnitureWallMaterial=/Game/Heavy/New2/Furniture/Materials/FntWall.FntWall
[Sandbox.Roots]
; Racines de packages UE (sans nom d'asset final)
FurnitureRoot=/Game/Heavy/New2/Furniture
DazAnimRoot=/Game/Heavy/Daz/Anims
QuaterniusRoot=/Game/Quaternius
QuaterniusUALRoot=/Game/Quaternius/UAL
[Sandbox.Content]
; Chemins disque, relatifs a Content/
ModuleRoot=Modules/vin_Base
WorldFile=Modules/vin_Base/World/work___vin01.lpworld
ChatAction=Modules/vin_Base/Interactions/chat.lpaction
QuaterniusUALDisk=Quaternius/UAL
[Sandbox.Sky]
; Optionnel : cubemap du SkyLight du sandbox
SkyCubemap=/Game/Heavy/New2/Buildings/Mats/urban_street_03_1k.urban_street_03_1k
SkyIntensity=0.6
[Sandbox.Output]
; Relatif a Saved/
MeasureOutputRoot=Measurement

View file

@ -2,6 +2,8 @@
> Toutes les commandes se lancent depuis `D:\Game\LifePlay-master 5.5`, sous PowerShell 7. Explications :
> `08_Guide_Developpeur.md`. Méthode : `10_Protocole_Operations.md`.
>
> **Atelier de mods (DC3, 2026-10-07)** : `BuildMod`, `NewMod`, `EditMod`, `HairPipeline`, `AnimPipeline`, `DeliverAnims`, `ModSource` et les lanceurs sandbox se lancent depuis `D:\Game\LifePlay-Sandbox` (`.\Tools\...`), avec `-GameRoot` facultatif (défaut : `[Workshop] GameRoot` de `Config\LPSandbox.ini` de l'addon) ; `ModSources\`, `AnimSources\` et `Tools\{anim,hairs,mods}` y sont aussi. Dans ce document, les chemins `.\Tools\<script de mod>` et `<projet>/Tools/Hair*.py` s'entendent donc dans l'addon.
```powershell
$UE = 'D:\Game\Launcher\Engine\UE_5.5\Engine'
@ -211,6 +213,7 @@ cmd /c '"<UE>\Build\BatchFiles\RunUAT.bat" BuildCookRun -project="<projet>\LifeP
Get-FileHash Releases\<version>\Windows\LifePlay-Windows.utoc, <build>\Windows\LifePlay\Content\Paks\LifePlay-Windows.utoc
# 5. Releases\ ne garde que la version publiée (les précédentes vers LifePlay_Backup_5.5\Releases) : l'archive des
# sources la contient (≈ 1,3 Go) ; exclure aussi les produits de cuisson des mods : -x!ModSources\*\Saved
# 6. (zips de mods d'assets : produits par LifePlay-Sandbox\Tools\BuildMod.ps1, lancé depuis D:\Game\LifePlay-Sandbox)
# 6. Mods officiels recuits contre la nouvelle version : .\Tools\BuildMod.ps1 -Name lpr_hair_pocolov
# Modules livrés à part : Tools\modules\next.json (essentiels : vin_Base, vin_VanillaRealistic ; lp_Revival retiré en H3d)
# Un zip par module (15_Modules_Optionnels.md §5), dans un même dossier que les mods d'assets ; diffusion sur Discord :

View file

@ -1,5 +1,7 @@
# 10 — Protocole suivi pendant les opérations
> Depuis le lot DC3 (2026-10-07), ces scripts, `ModSources/` et `AnimSources/` sont dans le dépôt addon `LifePlay-Sandbox` (même disposition relative ; lancer depuis l'addon, `D:\Game\LifePlay-Sandbox`).
> Règles appliquées du 2026-09-29 au 2026-10-02 pour toute modification du projet, et leçons tirées des
> erreurs commises. Elles valent pour toute reprise du projet, par un développeur comme par un agent.
> Détail des tests : `03_Plan_Migration_Tests.md` ; correctifs de contenu : `05_Correctifs_Isoles.md`.

View file

@ -1,5 +1,7 @@
# 13. Protocole de conversion d'une coiffure
> Depuis le lot DC3 (2026-10-07), ces scripts, `ModSources/` et `AnimSources/` sont dans le dépôt addon `LifePlay-Sandbox` (même disposition relative ; lancer depuis l'addon, `D:\Game\LifePlay-Sandbox`).
Ce protocole décrit comment ajouter au jeu une coiffure livrée en **maillage statique** (FBX de Fab ou d'ailleurs),
sans modifier aucun Blueprint. Il a été établi sur *Pocolov Hair 17* (lot O7e, `11_Audit_Optimisation.md` §16.8) puis
rejoué de bout en bout avec les outils ci-dessous : résultat identique au millimètre.

View file

@ -1,5 +1,7 @@
# 16. H3 : assets apportés par les mods (étude, 2026-10-03)
> Depuis le lot DC3 (2026-10-07), ces scripts, `ModSources/` et `AnimSources/` sont dans le dépôt addon `LifePlay-Sandbox` (même disposition relative ; lancer depuis l'addon, `D:\Game\LifePlay-Sandbox`).
Objectif : qu'un mod apporte ses propres maillages, textures et matériaux (coiffures d'abord, puis vêtements, meubles,
animations) **sans recompiler ni recuire le jeu**. Aujourd'hui, un mod `Content/Modules/<mod>` ne contient que du texte :
tout asset doit être cuit avec le jeu (lots H1, H2).

View file

@ -1,5 +1,7 @@
# 20. Chaîne des animations : de l'achat du pack au mod livré (étude, 2026-10-06)
> Depuis le lot DC3 (2026-10-07), ces scripts, `ModSources/` et `AnimSources/` sont dans le dépôt addon `LifePlay-Sandbox` (même disposition relative ; lancer depuis l'addon, `D:\Game\LifePlay-Sandbox`).
Objectif : qu'un pack d'animations, quel que soit son squelette d'origine, soit converti pour les corps du jeu
(Genesis 8 : Bridget, Michael), évalué et livré en mod **sans intervention manuelle**, avec un contrôle bloquant à
chaque étape. Base technique : la méthode validée dans la sandbox Mesure (CHG-188 à 204, mémoire « transfert de

View file

@ -60,7 +60,7 @@ Le lot S1 fixe le premier contrat jeu ↔ addon : **tous les chemins d'assets et
`Config/LPSandbox.ini`**, lu par un point d'accès unique. Détail : `etat/sandbox/S1/S1-SPEC.md` ; implémentation
`S1-IMPL.md` ; audit `S1-CART.md` ; validation `S1-VAL.md` (PASS statique ; compilation et runtime à confirmer).
- **Fichier** : `Config/LPSandbox.ini` (versionné, pas dans `Saved/`), lecture directe non hiérarchique (`FConfigFile::Read`).
- **Fichier** : `Config/LPSandbox.ini` (versionné, pas dans `Saved/` ; depuis DC3 il vit dans l'addon, avec sa section `[Workshop]` `GameRoot`), lecture directe non hiérarchique (`FConfigFile::Read`).
- **API** : `struct LIFEPLAY_API FLPSandboxConfig` (`Source/LifePlay/LPSandboxConfig.h/.cpp`), `static const FLPSandboxConfig& Get()`, chargé une fois. `LPPairSandbox.cpp` et `LPAnimRedirect::ClipPath` n'utilisent plus de littéral `/Game/...`.
- **Repli** : fichier, clé ou valeur vide absents => default = littéral historique (comportement identique sans ini). Un asset introuvable garde le journal `LogLifePlay` d'origine, sans repli silencieux.
- **19 clés, 6 sections** : `Sandbox.Mode` (`Mode` = `Pair` | `Interaction` | `Mesure`, `Saved/sandbox_mode.txt` reste prioritaire) ; `Sandbox.Assets` (`PersonBP`, `BridgetMesh`, `MichaelMesh`, `QuaterniusMannequinMesh`, `DazIdleFemale`, `DazIdleMale`, `FurnitureWallMaterial`) ; `Sandbox.Roots` (`FurnitureRoot`, `DazAnimRoot`, `QuaterniusRoot`, `QuaterniusUALRoot`) ; `Sandbox.Content`, chemins relatifs à `Content/` (`ModuleRoot`, `WorldFile`, `ChatAction`, `QuaterniusUALDisk`) ; `Sandbox.Sky` (`SkyCubemap`, `SkyIntensity`) ; `Sandbox.Output`, relatif à `Saved/` (`MeasureOutputRoot`).
@ -124,12 +124,12 @@ D:\Game\
- **Liaison** : `LifePlay.uproject` déclare `"AdditionalPluginDirectories": ["../LifePlay-Sandbox/Plugins"]` (chemin relatif au `.uproject`) et active `LPSandbox` dans `Plugins`. UBT et l'éditeur y trouvent `LPSandbox.uplugin` (modules `LPSandbox` et `LPAnimMeasure`) ; les DLL du plugin sont compilées avec le jeu (`Build.bat LifePlayEditor Win64 Development`).
- **Pourquoi pas un submodule** : l'addon est un `uproject` mince qui contient le plugin sous `Plugins/LPSandbox/`. Un submodule monté sur `Plugins/LPSandbox` du jeu donnerait `Plugins/LPSandbox/Plugins/LPSandbox/` (plugin imbriqué, non scanné au bon niveau) ; un submodule `External/LifePlay-Sandbox` dupliquerait l'addon à côté du sibling et rendrait ambigu l'exemplaire chargé. Un clone sibling évite les deux problèmes.
- **Pin** : le jeu consomme un commit précis de l'addon, consigné à la main dans `S3-INT.md` (aujourd'hui `97c0d134cd1388c3735add79d77ea98817e89fbe`, `lot/DC2-modtools`, DC2 module éditeur `LPModTools` et chemins propres à l'addon ; précédents : `3e9c32f1f27b1abdaf7731dc90496c27ee061718` DC1 `LPAnimRedirect` et `LPCoupleSim` rapatriés dans le plugin `LPAnimCore` du jeu, `16905c68070992f5139dab907b41b461fb765957` SB4 ouverture par déclencheur éphémère non mémorisée, `3d858527fccfdddd8711c7c7448fa001442e0b8a` SB2-O1 correction de `ShowRoom`, `fece7e9a0e63b3058b5d24d96d669a3c9c0e9add` SB2 bibliothèques de scène et de personnages, caméra du socle, état des modes sorti vers les mods, `82dae58380b76c45f60541fd7cb5a0a409700e10` SB1 registre de mods et barre latérale, `3b21fd180c04f0fa49a1b5c9ef1b517ade1cb362` SB0 conception de la sandbox modulaire, `f09c42e5a2bc613cf269cce76c878461ca41c8b7` S7-T2 uplugin, `c1fdd29e2c1036c7d6365ba6389eff5d2f8c8c96` S5-T2 `LPCoupleSim`, `f8102647ccdebad37e4977ccbf2c51e583823a80`, S4-T3). Le pin est mis à jour (hash dans `S3-INT.md` et README de l'addon) à chaque évolution de l'addon que le jeu doit prendre. Reproduire :
- **Pin** : le jeu consomme un commit précis de l'addon, consigné à la main dans `S3-INT.md` (aujourd'hui `79f0205b7281152dde7cd088086d8c813e0b20f5`, `lot/DC3-atelier`, DC3 atelier de mods (scripts, `ModSources`, `AnimSources`) dans l'addon ; précédents : `97c0d134cd1388c3735add79d77ea98817e89fbe` DC2 module éditeur `LPModTools` et chemins propres à l'addon, `3e9c32f1f27b1abdaf7731dc90496c27ee061718` DC1 `LPAnimRedirect` et `LPCoupleSim` rapatriés dans le plugin `LPAnimCore` du jeu, `16905c68070992f5139dab907b41b461fb765957` SB4 ouverture par déclencheur éphémère non mémorisée, `3d858527fccfdddd8711c7c7448fa001442e0b8a` SB2-O1 correction de `ShowRoom`, `fece7e9a0e63b3058b5d24d96d669a3c9c0e9add` SB2 bibliothèques de scène et de personnages, caméra du socle, état des modes sorti vers les mods, `82dae58380b76c45f60541fd7cb5a0a409700e10` SB1 registre de mods et barre latérale, `3b21fd180c04f0fa49a1b5c9ef1b517ade1cb362` SB0 conception de la sandbox modulaire, `f09c42e5a2bc613cf269cce76c878461ca41c8b7` S7-T2 uplugin, `c1fdd29e2c1036c7d6365ba6389eff5d2f8c8c96` S5-T2 `LPCoupleSim`, `f8102647ccdebad37e4977ccbf2c51e583823a80`, S4-T3). Le pin est mis à jour (hash dans `S3-INT.md` et README de l'addon) à chaque évolution de l'addon que le jeu doit prendre. Reproduire :
```powershell
cd D:\Game
git clone https://github.com/edujiro5972/LifePlay-Sandbox.git
git -C LifePlay-Sandbox checkout 97c0d134cd1388c3735add79d77ea98817e89fbe
git -C LifePlay-Sandbox checkout 79f0205b7281152dde7cd088086d8c813e0b20f5
# le jeu doit être cloné à côté : D:\Game\LifePlay-master 5.5
```
@ -188,13 +188,14 @@ Le lot S5 ferme la **dérive de fork** entre la sandbox (`LPSandbox/LPCoupleSim.
- [x] **DC0** : cartographie et SPEC du découplage jeu / sandbox (`etat/sandbox/DC0/DC0-SPEC.md`, approuvée par l'utilisateur 2026-10-07) ; plan `24_Plan_Decouplage_Sandbox.md`.
- [x] **DC1** : Plugin LPAnimCore dans le jeu, LPAnimMeasure réduit à la mesure (DC1-T1..T5) (DC1-VAL PASS, contrôle visuel utilisateur 2026-10-07 ; tag addon lot-DC1-valide).
- [x] **DC2** : Commandlets de mods vers un module éditeur de la sandbox (DC2-T1..T5) (DC2-VAL PASS ; tag addon lot-DC2-valide ; le jeu compile et démarre sans l'addon).
- [ ] **DC3** : Scripts, données et sources de mods vers la sandbox (DC3-T1..T5).
- [x] **DC3** : Scripts, données et sources de mods vers la sandbox (DC3-T1..T5) (DC3-VAL PASS ; tag addon lot-DC3-valide ; atelier de mods dans l'addon).
- [ ] **DC4** : Preuve « jeu seul », docs, évaluation (DC4-T1..T4).
- [ ] Intégration des déplacements dans le jeu à améliorer (remarque utilisateur au contrôle visuel DC1, 2026-10-07) ; lot propriétaire : à définir, hors DC.
- [x] Reports DC0 (lot propriétaire **DC1**, traité : texte corrigé en § Rupture du couplage) : § Rupture du couplage : la mention d'un appel à SetUALRoot par FLifePlayModule::StartupModule() est périmée ; seul l'addon l'appelle (LPSandbox.cpp:17, LPSandboxMeasure.cpp:39) — voir DC0-CART b2.
- [ ] Report DC2 : `LPAnimCatalog` ignore `-DryRun` et a des valeurs par défaut (pack quaternius) : lancé sans arguments, il réécrit `Saved/AnimPipeline/quaternius/fiches_calculees.json` (DC2-VAL anomalie 1) — lot propriétaire DC3.
- [x] Report DC2 (traité en DC3 : `-Livraison=` obligatoire) : `LPAnimCatalog` ignore `-DryRun` et a des valeurs par défaut (pack quaternius) : lancé sans arguments, il réécrit `Saved/AnimPipeline/quaternius/fiches_calculees.json` (DC2-VAL anomalie 1) — lot propriétaire DC3.
- [ ] Report DC2 : `LifePlay.Build.cs` garde `Json` avec un commentaire AP1 périmé (plus aucun usage dans `LifePlay`) — lot propriétaire DC4.
- [ ] Report DC2 : sans addon, l'absence est silencieuse au runtime ; l'éditeur/UBT affiche `AdditionalPluginDirectory … not found` (avertissement, non bloquant) — pour information, DC4 docs.
- [ ] Report DC3 : après un test sans addon, UBT réutilise un `Makefile.bin` sans les modules de l'addon : le déplacer avant de recompiler (DC3-BUILD) — à écrire dans `09`/`18` — lot propriétaire DC4.
## Lots (S0 … S8)
| Lot | Objet (une ligne) |

View file

@ -1,5 +1,7 @@
# 23. Cycle de vie d'un mod (2026-10-07)
> Depuis le lot DC3 (2026-10-07), ces scripts, `ModSources/` et `AnimSources/` sont dans le dépôt addon `LifePlay-Sandbox` (même disposition relative ; lancer depuis l'addon, `D:\Game\LifePlay-Sandbox`).
Référence pour les développeurs du jeu et des outils de mods : ce qu'est un mod LifePlay, comment il est fabriqué,
livré, découvert, activé et consommé, quelles propriétés sont réellement lues, et ce qui pourrait évoluer.
Constats relevés dans le code du jeu, des Blueprints (`GI`) et du moteur UE 5.5 installé

View file

@ -1131,6 +1131,7 @@ Validation L7c/L7d (2026-10-02, parc, même cadrage) : la nuit reste claire (sol
| CHG-155 | 2026-10-05 | ShowCouple | Un lit est reconnu par son nom. Il ne bloque un déplacement que vers le bas, pour se poser ; le retour horizontal n'est plus arrêté et le contact ne renvoie plus la boîte | après avoir quitté le lit, la boîte ne pouvait plus y revenir | revenir au test `DoesPackageExist` et au filtre du flanc seul |
| CHG-156 | 2026-10-05 | Clavier | `LPKeyboardSubsystem` : bouton « Clavier » ajouté à l'exécution dans `MainMenu.SettingsScrollBox` ; panneau pour changer, ajouter ou retirer une touche (actions et axes clavier). Écrit dans `Saved/Config/Windows`, pas dans `GameSettings.sav` | pouvoir réaffecter les touches, dont Q, sans modifier les Blueprints | retirer `LPKeyboardSubsystem.h/.cpp` et la dépendance `UMG` |
| CHG-158 | 2026-10-05 | Réglages | `LPSettingsSubsystem` : onglets Audio, Video, Lewd, General, Keyboard, Misc au-dessus de `SettingsScrollBox`. Les lignes du Blueprint restent en place et sont montrées selon leur texte ; le reste va dans Misc. Keyboard affiche la liste des touches | la liste unique ne montrait pas le clavier | retirer `LPSettingsSubsystem.h/.cpp` |
| CHG-236 | 2026-10-07 | DC3 | jeu : retrait (57 fichiers suivis) de `Tools/BuildMod.ps1`, `NewMod.ps1`, `EditMod.ps1`, `ModSource.ps1`, `HairPipeline.ps1`, `HairImportFbx.py`, `HairMaterials.py`, `FbxMaterialMap.py`, `ImportQuaternius.py`, `DiagnosePose.py`, `Tools/anim/`, `Tools/hairs/`, `Tools/mods/`, `ModSources/`, `AnimSources/`, `Config/LPSandbox.ini`, `Tools/debug/*_sandbox.bat`, `Docs/Modding/FullGuides/Creating_Asset_Mods.md`, `Docs/Modding/ShortGuides/explanation.lphair` (contenu disque déplacé, non supprimé, vers `D:\Game\LifePlay_Backup_5.5\DC3\deplaces_du_jeu`) ; références `SplitModules.ps1`, `LPModInfoSubsystem.h`, `BuildDebug.ps1`, `explanation.lpmod`, `README.md`, `.gitignore` ; docs (`21_Sandbox_Addon.md`, `09_Commandes_Utiles.md`, `13_Protocole_Coiffure.md`, `16_H3_Mods_Paquets.md`, `10_Protocole_Operations.md`, `23_Cycle_De_Vie_Mod.md`, `20_Chaine_Animations.md`, `README.md`, `etat/sandbox/S3/S3-INT.md`, `etat/sandbox/DC3/*`) ; addon `D:\Game\LifePlay-Sandbox` : atelier de mods (`Tools/*`, `Tools/Roots.ps1` nouveau, `ModSources`, `AnimSources`, lanceurs, `Docs/Modding`, `Config/LPSandbox.ini` section `[Workshop]`, `.gitignore`), `LPAnimCatalogCommandlet.cpp` (`-Livraison=` obligatoire), `LPAnimMeasurePaths.cpp/.h` et `LPSandboxConfig.cpp` (replis vers le jeu retirés) (commits `1f4ec1f` et `79f0205b7281152dde7cd088086d8c813e0b20f5`, branche `lot/DC3-atelier`, tag `lot-DC3-valide`) | lot DC3 : l'atelier de mods vit dans l'addon ; cuisson de `lpr_anim_freeanims` depuis l'addon identique au mod publié (29 fichiers, SHA-256) ; tournée freeanims identique à SB2-REF (DC3-VAL PASS) ; piège UBT `Makefile.bin` (cache généré sans addon) traité. Motif : le jeu public ne doit plus embarquer l'outillage ni les sources de mods du dépôt privé `LifePlay-Sandbox` | pin `97c0d13` → `79f0205` (hash complet `79f0205b7281152dde7cd088086d8c813e0b20f5`) | `.\Tools\Restore.ps1 -Lot DC3` (jeu ; remettre aussi le contenu de `DC3\deplaces_du_jeu`) et `.\Tools\Restore.ps1 -Lot DC3-addon -ProjectRoot D:\Game\LifePlay-Sandbox` ; pin `97c0d13` |
| CHG-235 | 2026-10-07 | DC2 | jeu : `Source/LifePlayEditorTools/` (13 sources retirées : `LPAnimEvalCommandlet`, `LPAnimTwistCommandlet`, `LPAnimCatalogCommandlet`, `LPAnimIdentifyCommandlet`, `LPAnimChecks`, `LPHairFromStaticMeshCommandlet` en `.h/.cpp`, `LPAnimMethodSelect.h`), `LifePlayEditorTools.Build.cs`, docs (`21_Sandbox_Addon.md`, `20_Chaine_Animations.md`, `09_Commandes_Utiles.md`, `08_Guide_Developpeur.md`, `etat/sandbox/S3/S3-INT.md`, `etat/sandbox/DC2/*`) ; addon `D:\Game\LifePlay-Sandbox` : module `LPModTools` (nouveau), `LPAnimMeasurePaths.h/.cpp` (nouveau), `LPSkeletonProfile.cpp`, `LPPoseMeasure.cpp`, `LPAnimMeasure.Build.cs`, `LPSandboxConfig.cpp`, `LPSandbox.uplugin` (commit `97c0d134cd1388c3735add79d77ea98817e89fbe`, branche `lot/DC2-modtools`, tag `lot-DC2-valide`) | lot DC2 : les commandlets de mods (`LPAnimEval`, `LPAnimTwist`, `LPAnimCatalog`, `LPAnimIdentify`, `LPHairFromStaticMesh`) passent dans le module éditeur `LPModTools` de l'addon ; le jeu compile sans l'addon (DC2-BUILD) et démarre sans lui (DC2-VAL E5) ; résultats identiques à SB2-REF (DC2-VAL PASS). Motif : le jeu public ne doit plus dépendre du dépôt privé `LifePlay-Sandbox` pour son outillage éditeur | pin `3e9c32f` → `97c0d13` (hash complet `97c0d134cd1388c3735add79d77ea98817e89fbe`) | `.\Tools\Restore.ps1 -Lot DC2` (jeu) et `.\Tools\Restore.ps1 -Lot DC2-addon -ProjectRoot D:\Game\LifePlay-Sandbox` ; pin `3e9c32f` |
| CHG-234 | 2026-10-07 | DC1 | jeu : `Plugins/LPAnimCore/*` (nouveau plugin : `LPAnimRedirect.*`, `LPCoupleSim.h`, `LPCoupleSimCore.cpp`), `LifePlay.uproject`, `Source/LifePlay/LifePlay.Build.cs`, docs (`21_Sandbox_Addon.md`, `20_Chaine_Animations.md`, `07_Architecture_5.5.md`, `etat/sandbox/S3/S3-INT.md`, `etat/sandbox/DC1/*`) ; addon `D:\Game\LifePlay-Sandbox` : `LPSandbox.uproject`, `LPSandbox.uplugin`, `LPAnimMeasure.Build.cs`, `LPAnimMeasure.h`, `LPSandbox.Build.cs`, suppression de `LPAnimRedirect.*`, `LPCoupleSim.h`, `LPCoupleSimCore.cpp` (commit `3e9c32f1f27b1abdaf7731dc90496c27ee061718`, branche `lot/DC1-animcore`, tag `lot-DC1-valide`) | lot DC1 : `LPAnimRedirect` et `LPCoupleSim` rapatriés dans le plugin `LPAnimCore` du jeu, `LPAnimMeasure` réduit à la mesure ; compilation PASS, DC1-VAL PASS, contrôle visuel utilisateur 2026-10-07 ; report DC0 SetUALRoot traité. Motif : le jeu public doit compiler sans le dépôt privé `LifePlay-Sandbox` (premier pas du découplage) | pin `16905c6` → `3e9c32f` (hash complet `3e9c32f1f27b1abdaf7731dc90496c27ee061718`) | `.\Tools\Restore.ps1 -Lot DC1` (jeu) et `.\Tools\Restore.ps1 -Lot DC1-addon -ProjectRoot D:\Game\LifePlay-Sandbox` ; pin `16905c6` |
| CHG-233 | 2026-10-07 | DC0 | `Docs/Migration/24_Plan_Decouplage_Sandbox.md` (nouveau), `Docs/Migration/etat/sandbox/DC0/DC0-CART.md`, `DC0-SPEC.md`, `DC0-VAL.md` (nouveaux) | lot DC0 : plan du découplage jeu / sandbox (lots DC0 à DC4), cartographie et SPEC approuvées par l'utilisateur le 2026-10-07 (plugin `LPAnimCore` dans le jeu, module éditeur `LPModTools` et tout l'outillage de mods dans l'addon, dépôt Sandbox privé) ; documentation seule, aucun code. Motif : le jeu public doit compiler sans le dépôt privé `LifePlay-Sandbox` (`LifePlay` et `LifePlayEditorTools` dépendent de `LPAnimMeasure`) | aucun pin (documentation seule) | supprimer ces fichiers (documentation seule) ; rien n'est commité dans le dépôt du jeu ni poussé |

View file

@ -1,5 +1,7 @@
# Migration LifePlay 4.27 → 5.5 — point d'entrée
> Depuis le lot DC3 (2026-10-07), ces scripts, `ModSources/` et `AnimSources/` sont dans le dépôt addon `LifePlay-Sandbox` (même disposition relative ; lancer depuis l'addon, `D:\Game\LifePlay-Sandbox`).
La migration a été reprise le 2026-09-29, après l'échec d'un premier portage.
**État au 2026-10-02** :

View file

@ -0,0 +1,105 @@
# DC3-BUILD — Compilation éditeur LifePlay
**Fiche** : DC3-T3a | **Modèle prévu** : Haiku | **Modèle utilisé** : Haiku
**Agent** : A-BUILD | **Date** : 2026-10-07
---
## Commande
```powershell
& 'D:\Game\Launcher\Engine\UE_5.5\Engine\Build\BatchFiles\Build.bat' LifePlayEditor Win64 Development `
-Project='D:\Game\LifePlay-master 5.5\LifePlay.uproject' -WaitMutex `
*> 'D:\Game\LifePlay-master 5.5\Docs\Migration\etat\sandbox\DC3\DC3_Build_LifePlayEditor.txt'
```
---
## Résultats
### Code de sortie
**0** — Compilation réussie sans erreurs de build.
### Erreurs
Aucune
### Avertissements
Aucun
### Fichiers compilés
```
[1/4] Compile [x64] Module.LifePlay.cpp
[2/4] Link [x64] UnrealEditor-LifePlay.lib
[3/4] Link [x64] UnrealEditor-LifePlay.dll
[4/4] WriteMetadata LifePlayEditor.target
```
**Note** : Le système de compilation incrémental (adaptive non-unity build) a détecté et compilé `Module.LifePlay.cpp`. Les fichiers `LPAnimMeasurePaths.cpp`, `LPSandboxConfig.cpp`, `LPAnimCatalogCommandlet.cpp` n'apparaissent pas dans le journal de compilation.
### DLL générées
| Fichier | LastWriteTime |
|---------|---------------|
| UnrealEditor-LPAnimMeasure.dll | 07/10/2026 18:34:36 |
| UnrealEditor-LPModTools.dll | 07/10/2026 18:34:50 |
| UnrealEditor-LPSandbox.dll | 07/10/2026 18:34:55 |
### Dossier Binaries de l'addon
`D:\Game\LifePlay-Sandbox\Plugins\LPSandbox\Binaries` : **N'existe pas** ✓
### Journal complet
Chemin : `D:\Game\LifePlay-master 5.5\Docs\Migration\etat\sandbox\DC3\DC3_Build_LifePlayEditor.txt`
---
## Verdict
✓ **Compilation réussie** — Code de sortie 0, pas d'erreurs, DLL à jour, structure attendue.
---
## Relance après déplacement du Makefile.bin (DC3-T3a-fix) — Modèle prévu haiku | utilisé haiku
### Commande
```powershell
& 'D:\Game\Launcher\Engine\UE_5.5\Engine\Build\BatchFiles\Build.bat' LifePlayEditor Win64 Development -Project='D:\Game\LifePlay-master 5.5\LifePlay.uproject' -WaitMutex *> 'D:\Game\LifePlay-master 5.5\Docs\Migration\etat\sandbox\DC3\DC3_Build_LifePlayEditor_2.txt'
```
### Code de sortie
**0** — Compilation réussie.
### Verdict
Succès. Les modules de l'addon (LPAnimMeasure, LPSandbox, LPModTools) ont été recompilés et les DLL ont été reliées après suppression du cache Makefile.bin.
### Erreurs
Aucune
### Avertissements
Aucun
### Lignes Compile [x64]
```
[1/11] Compile [x64] LPAnimCatalogCommandlet.cpp
[2/11] Compile [x64] LPAnimIdentifyCommandlet.cpp
[4/11] Compile [x64] Module.LPAnimMeasure.cpp
[8/11] Compile [x64] Module.LPSandbox.cpp
```
### Lignes Link [x64]
```
[3/11] Link [x64] UnrealEditor-LPModTools.lib
[5/11] Link [x64] UnrealEditor-LPAnimMeasure.lib
[6/11] Link [x64] UnrealEditor-LPAnimMeasure.dll
[7/11] Link [x64] UnrealEditor-LPModTools.dll
[9/11] Link [x64] UnrealEditor-LPSandbox.lib
[10/11] Link [x64] UnrealEditor-LPSandbox.dll
```
### DLL générées
| Fichier | LastWriteTime |
|---------|---------------|
| UnrealEditor-LPAnimMeasure.dll | 07/10/2026 19:36:42 |
| UnrealEditor-LPModTools.dll | 07/10/2026 19:36:42 |
| UnrealEditor-LPSandbox.dll | 07/10/2026 19:36:45 |
### Journal complet
Chemin : `D:\Game\LifePlay-master 5.5\Docs\Migration\etat\sandbox\DC3\DC3_Build_LifePlayEditor_2.txt`

View file

@ -0,0 +1,29 @@
# DC3-DOC — Pin et documentation du lot DC3
**Fiche DC3-T4 (A-INT, partie pin) et DC3-T5 (A-DOC) — Modèle prévu sonnet | utilisé sonnet**
- Date : 2026-10-07. Aucune commande git d'écriture, aucun fichier de code. Les fins de ligne (LF) des fichiers sont conservées.
- Nouveau pin de l'addon : `79f0205b7281152dde7cd088086d8c813e0b20f5` (`79f0205`, branche `lot/DC3-atelier`, tag `lot-DC3-valide`, local, non poussé ; commits `1f4ec1f` et `79f0205`). Pin précédent : `97c0d134cd1388c3735add79d77ea98817e89fbe` (DC2).
## DC3-T4 (partie pin)
| Fichier | Lignes touchées |
|---|---|
| `Docs/Migration/etat/sandbox/S3/S3-INT.md` | 11 (commit pin), 13 (DC2 ajouté en tête des pins précédents), 49 (checkout) |
| `Docs/Migration/21_Sandbox_Addon.md` | 127 (paragraphe Pin : nouveau hash en tête, DC2 en tête des précédents), 132 (checkout) |
| `D:\Game\LifePlay-Sandbox\README.md` | commit pin, pin précédent, checkout |
## DC3-T5 (documentation)
| Fichier | Modification |
|---|---|
| `Docs/Migration/Journal_Modifications.md` | 1134 : CHG-236 (7 colonnes), juste au-dessus de CHG-235 |
| `Docs/Migration/21_Sandbox_Addon.md` | `Config/LPSandbox.ini` : précision « vit dans l'addon » (§ S1) ; DC3 coché ; report DC2 `LPAnimCatalog` coché (traité en DC3) ; nouveau report DC3 non coché (`Makefile.bin` d'UBT, lot propriétaire DC4, à écrire dans `09`/`18`) |
| `Docs/Migration/09_Commandes_Utiles.md` | note en tête (commandes de l'atelier lancées depuis `D:\Game\LifePlay-Sandbox`, `-GameRoot`, `[Workshop] GameRoot`) ; § 7 : les zips de mods d'assets viennent de `LifePlay-Sandbox\Tools\BuildMod.ps1` |
| `Docs/Migration/13_Protocole_Coiffure.md`, `16_H3_Mods_Paquets.md`, `10_Protocole_Operations.md`, `23_Cycle_De_Vie_Mod.md`, `20_Chaine_Animations.md`, `README.md` | note courte en tête (scripts, `ModSources/` et `AnimSources/` dans l'addon ; lancer depuis l'addon) ; aucun chemin réécrit |
| `D:\Game\LifePlay-Sandbox\README.md` | section « Atelier de mods (lot DC3) » ; mentions périmées des lanceurs « restant dans le jeu » corrigées |
| `Docs/Migration/etat/sandbox/DC3/DC3-DOC.md` | nouveau |
## Limites
Les chemins `.\Tools\...`, `<projet>/Tools/...` et `ModSources\...` des documents 09, 13, 16, 20, 23 ne sont pas réécrits un à un : la note de tête indique qu'ils s'entendent dans l'addon. Le piège du `Makefile.bin` n'est pas encore écrit dans `09`/`18` (report DC4).

View file

@ -0,0 +1,68 @@
# DC3-IMPL — Atelier de mods dans l'addon
- **Fiches** : DC3-T1 (A-IMPL-H, scripts structurants) et DC3-T2 (A-IMPL-L, déplacements simples) — **Modèle** :
M3 opus | opus pour les deux (DC3-T2 fait dans la foulée par la session principale, motif : les déplacements et
les chemins des scripts sont imbriqués, un second agent aurait dû relire tout le contexte).
- **Dépend de** : DC0-SPEC § 4. **Instantané addon** : `D:\Game\LifePlay_Backup_5.5\DC3-addon` (cibles absentes +
`Config\LPSandbox.ini`, `.gitignore`, `README.md`). **Branche addon** : `lot/DC3-atelier` (depuis `lot/DC2-modtools`).
- **Jeu** : côté jeu, rien n'est encore retiré (attente de la fin de la réécriture d'historique PUB1, autre session).
## Copie jeu → addon (même disposition relative)
746 fichiers comparés par SHA-256 : 0 différence (copie brute, avant adaptation). Une erreur de copie
(`Tools/anim/methodes/*.json` arrivés dans `Tools/anim/`) corrigée aussitôt, contenu vérifié identique.
| Élément | Addon |
|---|---|
| `Tools/BuildMod.ps1`, `NewMod.ps1`, `EditMod.ps1`, `ModSource.ps1`, `HairPipeline.ps1` | `Tools/` (adaptés, voir ci-dessous) |
| `Tools/HairImportFbx.py`, `HairMaterials.py`, `FbxMaterialMap.py`, `ImportQuaternius.py`, `DiagnosePose.py` | `Tools/` (inchangés) |
| `Tools/anim/` (`AnimPipeline.ps1`, `DeliverAnims.ps1` adaptés ; `.py`, `profiles/`, `methodes/` inchangés) | `Tools/anim/` — les 2 résidus non suivis `Tools/anim/21_Sandbox_Addon.md`, `Journal_Modifications.md` du jeu **non copiés** |
| `Tools/hairs/`, `Tools/mods/` | idem |
| `ModSources/` (3 mods, `*/Content` compris), `AnimSources/` | idem ; `ModSources/*/Content/` ignoré par git (comme dans le jeu) |
| `Tools/debug/LifePlay_{pair,interaction,measure}_sandbox.bat` | `Tools/debug/` (adaptés) |
| `Docs/Modding/FullGuides/Creating_Asset_Mods.md`, `ShortGuides/explanation.lphair` | `Docs/Modding/` |
## Deux racines
- Nouveau `Tools/Roots.ps1` : `$LPSbRoot` (addon, déduit de l'emplacement de `Roots.ps1`) et `Resolve-LPRoots
[-GameRoot]` → racine du jeu (paramètre, sinon `Config/LPSandbox.ini` `[Workshop] GameRoot=..\LifePlay-master 5.5`,
sinon erreur ; contrôle de `LifePlay.uproject`). Testé : `D:\Game\LifePlay-master 5.5` / `D:\Game\LifePlay-Sandbox`.
- `Config/LPSandbox.ini` de l'addon : section `[Workshop]` ajoutée (la copie du jeu reste lue par repli jusqu'à son retrait).
- Chaque script reçoit `-GameRoot` (facultatif) ; `$Root` = jeu ; passent à `$LPSbRoot` : `ModSources`, `AnimSources`,
`Tools/mods`, `Tools/anim/*.py`, `RetargetPack.py`, `ImportQuaternius.py` (`source.import` des `pack.json`),
lanceur d'interaction, `BuildMod`/`DeliverAnims` appelés par le pilote (avec `-GameRoot $Root`) ; restent au jeu :
projet, `Config/DefaultGame.ini`, `Releases`, `Mods`, `Content`, `Saved` (dont `travail` des `pack.json`),
`Licenses`, `Tools/RunCommandlet.ps1`, `Tools/BuildCollections.py` (`HairPipeline` : chemin explicite vers le jeu).
- `ModSource.ps1` : `Source = $LPSbRoot\ModSources\<Mod>`, `Mounted = <jeu>\Mods\<Mod>` (signature inchangée).
- Lanceurs `.bat` : `GAME` = `%LP_GAMEROOT%` sinon `%~dp0..\..\..\LifePlay-master 5.5` ; `PROJ`, `ROOT` (Mesure : `Saved\sandbox_mode.txt`) en dérivent.
- Analyse syntaxique PowerShell (`Parser::ParseFile`) : 6 scripts sans erreur.
## Reste (après la fin de PUB1)
Jeu : `git rm` des éléments copiés (scripts, `Tools/anim|hairs|mods`, `Config/LPSandbox.ini`, lanceurs, guide,
`ModSources` suivi) ; `ModSources/`, `AnimSources/` et fichiers non suivis **déplacés** vers la sauvegarde (jamais
supprimés) ; références (`SplitModules.ps1`, `LPModInfoSubsystem.h:8`, `BuildDebug.ps1:39`, `explanation.lpmod`,
README, `.gitignore`) ; retrait des replis `ProjectDir()` / `ProjectConfigDir()` de `LPAnimMeasurePaths` et
`LPSandboxConfig` (addon) — **reporté après DC3-T3** pour garder un repli pendant la validation.
## Report DC2 traité
`LPModTools/LPAnimCatalogCommandlet.cpp` : `-Livraison=` devient obligatoire (erreur d'usage, code 1) ; l'ancien défaut
(pack quaternius) faisait réécrire `Saved/AnimPipeline/quaternius/fiches_calculees.json` par un lancement sans
argument (DC2-VAL, anomalie 1). Le pilote passe toujours `-Livraison=` (inchangé). Recompilation nécessaire (DC3-T3).
## Après PUB1 : retrait côté jeu (2026-10-07)
- Addon commité d'abord : `1f4ec1f` (copie + adaptations, 164 fichiers).
- Instantané jeu `D:\Game\LifePlay_Backup_5.5\DC3` (59 fichiers : les 53 suivis retirés + `SplitModules.ps1`,
`LPModInfoSubsystem.h`, `BuildDebug.ps1`, `explanation.lpmod`, `README.md`, `.gitignore`).
- `git rm --cached` de 57 fichiers suivis (dont `AnimSources/*/pack.json`, `Tools/DiagnosePose.py`,
`Tools/ImportQuaternius.py`, suivis entre-temps par les commits de phase) ; **tout** le contenu disque correspondant
(754 fichiers, dont `ModSources` 133 Mo et les 2 résidus de `Tools/anim`) **déplacé** vers
`D:\Game\LifePlay_Backup_5.5\DC3\deplaces_du_jeu` (rien supprimé). Copie addon vérifiée identique (`pack.json`).
- Références : `SplitModules.ps1:21,41`, `LPModInfoSubsystem.h:8`, `BuildDebug.ps1:39`, `explanation.lpmod:17`
(dépôt de l'atelier), `README.md` (outils et guide dans un dépôt d'atelier séparé ; le jeu se construit sans lui),
`.gitignore` (`/ModSources/*/…` → `/ModSources/`, `/AnimSources/`). Seul reste un commentaire périmé dans
`LifePlay.Build.cs:30` (report DC4, avec `Json`).
- Addon : replis vers le jeu retirés (`LPAnimMeasurePaths::ToolsAnimDir()` → `<addon>/Tools/anim` seul ;
`LPSandboxConfig` → `<addon>/Config/LPSandbox.ini` seul).

View file

@ -0,0 +1,77 @@
# DC3-VAL — Validation automatique du lot DC3 (DC3-T3b)
**Fiche DC3-T3b — Modèle prévu sonnet | utilisé sonnet** (agent A-VAL)
- Date : 2026-10-07. Build testé : celui de `DC3-BUILD.md` (code 0, non recompilé). Lot DC3 : atelier de mods dans l'addon `D:\Game\LifePlay-Sandbox`, nouveau `Tools\Roots.ps1`, `LPAnimCatalog` exigeant `-Livraison=`.
- Méthode : celle de `DC2-VAL.md` (pwsh 7, aucun éditeur avant chaque lancement, comparaison SHA-256 contre `SB2-REF`). Aucune commande git hors `status`, aucun script/source/ini modifié, aucune recompilation, rien supprimé.
- Journaux et pièces : `D:\Game\LifePlay_Backup_5.5\DC3-VAL\` (`v4.log`, `v5.log`, `v5.cmd`, `v6.log`, `v7.log`, `V5_LifePlay.log`, `V6_LifePlay.log`, `V8_LifePlay.log`, `DC3_LPAnimCatalog_20261007-194818.log`, `mods\` zip produit, `x\` extraction des deux zips, `dc3_test_mod\` mod de test déplacé).
## Tableau des essais
| Essai | Attendu | Constaté | Verdict |
|---|---|---|---|
| V0 préalable | `<jeu>\Mods` vide ; `Tools\BuildMod.ps1`, `Tools\anim`, `ModSources`, `AnimSources`, `Config\LPSandbox.ini` absents du jeu | `Mods` présent et vide ; les 5 `Test-Path` = False. 0 UnrealEditor. | PASS |
| V1 racines | `D:\Game\LifePlay-master 5.5` puis `D:\Game\LifePlay-Sandbox` ; erreur explicite si racine invalide | `D:\Game\LifePlay-master 5.5` / `D:\Game\LifePlay-Sandbox`. `Resolve-LPRoots 'D:\Nexiste\Pas'` : `Exception: ...\Tools\Roots.ps1:37 ... LifePlay.uproject introuvable dans D:\Nexiste\Pas (racine du jeu)` | PASS |
| V2 ModSource | montage/démontage, addon retrouvé, `Mods` vide | `Mod lpr_anim_freeanims monte dans Mods\lpr_anim_freeanims` ; `Get-StrayMods` : `lpr_anim_freeanims` ; `Mod lpr_anim_freeanims remis dans ModSources\lpr_anim_freeanims`. `ModSources\lpr_anim_freeanims\lpr_anim_freeanims.uplugin` = True ; `<jeu>\Mods` : 0 entrée. | PASS |
| V3 NewMod | 3 fichiers + `Tools\mods\dc3_test_mod.json` dans l'addon ; déplacé ; git propre | `Mod cree : D:\Game\LifePlay-Sandbox\ModSources\dc3_test_mod` ; `.uplugin`, `Module\dc3_test_mod.lpmod`, `Module\CREDITS.txt`, `Tools\mods\dc3_test_mod.json` tous True (dans l'addon). Déplacés (Move-Item) vers `DC3-VAL\dc3_test_mod\`. `git status --short` de l'addon : plus aucune trace de `dc3_test_mod` (restent les 3 `M` DC3 attendus, voir V9). `<jeu>\Mods` vide. | PASS |
| V4 cuisson de mod | code 0, même liste de fichiers | code 0, 28 s (voir détail). Zip 4 416 299 octets, 29 fichiers, SHA-256 `F4D011F9...C62DE`. `<jeu>\Mods` vide ; `ModSources\lpr_anim_freeanims\lpr_anim_freeanims.uplugin` revenu. | PASS |
| V5 conversion à blanc des rigs | `RETARGET rigs prets jeton ...` | `Conversion des clips de /Game/Quaternius/UAL vers Bridget et Michael (methode ue4, rigs)` / ` RETARGET rigs prets jeton 5a32ccdcbe35` / `Cuisson non lancee`. 59 s. Journal éditeur : `LogLPAnimMeasure: PATHS racine addon D:/Game/LifePlay-Sandbox` ; `LogPython: Warning: RETARGET methode ue4 profil ue4_mannequin calques T` ; `RETARGET profil UAL1_Standard -> ue4_mannequin (100%)`. Plantage à la fermeture : non observé, 0 éditeur après. | PASS |
| V6 tournée freeanims `-From 5 -To 5` | code 0, `ok`, 73 fichiers comparés | code 0, 424 s, `verdict : ok (424 s)`, `[ok] une vignette <Clip>.png par clip`, `[ok] une planche <Clip>.jpg + .json par clip`. Voir détail. | PASS |
| V7 LPAnimCatalog sans argument | `Usage`, code 1, `fiches_calculees.json` inchangé | `LogLPAnimCatalog: Error: Usage : -Livraison=<livraison.json> [-Out=<dossier>] [-SeuilBoucle=1] [-SeuilSurPlace=5]`, `code=1 duree=12s erreurs=2`. SHA-256 de `Saved\AnimPipeline\quaternius\fiches_calculees.json` avant = après = `5DBBA632FAF3821415D0F7EF9CD74226EA694501BCC69B96565170194F9417AE` (date de modification aussi inchangée, 00:33:24). | PASS |
| V8 lanceur pair | `Ouvert :`, Modules, PATHS | `LifePlay_pair_sandbox.bat` ; `Ouvert :` en moins de 60 s. `LogLPAnimMeasure: PATHS racine addon D:/Game/LifePlay-Sandbox` ; `LogLPSandboxMods: Display: Modules : 7 sandbox, 16 jeu ; ordre : lps_system_core, lps_system_animmeasure, lps_lib_scene, lps_lib_actors, lps_couple, lps_interaction, lps_asset_integration` ; `LogLPSandboxMods: Display: Ouvert : lps_couple`. Processus UnrealEditor arrêté (`Stop-Process`). | PASS |
| V9 état final | `Mods` vide, 0 éditeur, `Open=lps_couple`, git status | `<jeu>\Mods` : 0 ; 0 UnrealEditor ; `Saved\sandbox_mods.ini` : `Order=lps_couple,lps_interaction,lps_asset_integration`, `Open=lps_couple`, 3 modules `true`. Git : voir ci-dessous. | PASS |
## Détail V4 : cuisson `lpr_anim_freeanims`
- Version lue dans le jeu 2026.10.1.6, journal de cuisson `Saved\Logs\portage\mods\lpr_anim_freeanims_20261007_193840.log`. Sorties : `20 paquet(s) cuit(s)`, `conteneur : 20 fichier(s), tous sous Mods/lpr_anim_freeanims/`, `Archive : ...\DC3-VAL\mods\lpr_anim_freeanims-1.0-for-LifePlay-2026.10.1.6.zip (4 416 299 octets, 29 fichiers ...)`. Pas de dossier `precedent_*` (sortie vers un dossier neuf).
- Comparaison avec `D:\Game\LifePlay_Publication\Mods\lpr_anim_freeanims-1.0-for-LifePlay-2026.10.1.6.zip` (7-Zip, les deux zips extraits) : 41 entrées (dossiers compris) de chaque côté, 29 fichiers de chaque côté, **liste identique** (aucun fichier en plus ni en moins), taille de l'archive identique (4 416 299 octets).
- Comparaison fichier par fichier (SHA-256) : **les 29 fichiers sont octet pour octet identiques**, y compris `lpr_anim_freeanimsLifePlay-Windows.pak` (2 718 o), `.ucas` (642 192 o), `.utoc` (2 485 o) ; seul l'horodatage du système de fichiers du `.pak` diffère (non compris dans le contenu). Aucun écart de taille.
- `.lpmod` livré : SHA-256 identique (`0B1B3CAE...4DD9`), diff texte vide (`MODULE_PACKAGED: 2026-10-07` et `MODULE_GAME_VERSION: 2026.10.1.6` identiques dans les deux).
- Le SHA-256 du zip précédent est `F518B0EB...EAC` contre `F4D011F9...62DE` : différence due à l'enveloppe zip (horodatages des entrées), pas au contenu.
## Détail V6 : comparaison complète contre `SB2-REF\freeanims\`
Règle de DC2-VAL : SHA-256 de chaque fichier commun ; `5_evaluation.json` et `2_identification.json` comparés en JSON hors `debut`/`fin`/`duree_s` ; `etat.json` exclu ; ligne « Généré le » de `rapport.html` exclue.
- **73 fichiers comparés, liste 73 / 73 identique** (`Compare-Object` vide).
- 50 fichiers au SHA-256 identique. 23 différents :
- `etat.json` (même taille 1 960 o), `rapport.html` (seule ligne différente : `Généré le` 07/10/2026 12:31:18 → 19:41:00), `etapes\2_identification.json` (JSON égal hors dates), `etapes\5_evaluation.json` (JSON égal hors dates ; verdict `ok` des deux côtés, 0 avertissement des deux côtés ; `duree_s` 424 vs 435).
- **19 images** (10 planches `.jpg` + 9 vignettes `.png`), toutes dans les 20 images connues non déterministes (AirKiss, AttackInLevitation2, ChillDriving, ComboHands, EatingOnParty, FlyingUp, GoForward, Rest, RightLegKick2, Talk), écart de taille maximal 0,16 % (`AS_Talk.jpg` 144 201 → 144 435). `AS_ChillDriving.png` est cette fois identique, d'où 19 sur 20.
- Tous les fichiers de données (`rapport.json`, CSV, `retarget_pose_*.txt`, etc.) identiques.
- Journal d'éditeur (`V6_LifePlay.log`) : `LogLPAnimMeasure: PATHS racine addon D:/Game/LifePlay-Sandbox` ; `METHODE ue5 (calques en pose A, profil source ue5_mannequin)` ; `PROFIL squelette SK_Mannequin (161 os) -> ue5_mannequin, 100% des os du profil` ; `PROFIL squelette bridget_gp_Skeleton (191 os) -> genesis8, 100% des os du profil` ; `PROFIL squelette michael_Skeleton (183 os) -> genesis8, 100%` ; `LogLPSandbox: Warning: TOURNEE debut 10 clips, 30 etapes` ; `LogLPSandboxMods: Display: Ouvert : lps_asset_integration (declencheur, non memorise)`. Aucune erreur « profil » ou « methode » introuvable (les seules lignes trouvées par la recherche `profil` sont `Deviceprofile LinuxArm64... not found`, sans rapport, du moteur). Seul `Error` du journal : `LogLifePlay: Error: Coiffures : GI.HairstyleTable ou GI.MorphableHairs introuvable sur GameInstance_0, table laissee telle quelle` (connu, propre à la sandbox, déjà noté à DC2-VAL).
- `Open=lps_couple` conservé après la tournée.
## État final (V9)
- `git -C D:\Game\LifePlay-Sandbox status --short` (changements DC3 non commités, attendu : retrait des replis vers le jeu) :
```
M Plugins/LPSandbox/Source/LPAnimMeasure/LPAnimMeasurePaths.cpp
M Plugins/LPSandbox/Source/LPAnimMeasure/LPAnimMeasurePaths.h
M Plugins/LPSandbox/Source/LPSandbox/LPSandboxConfig.cpp
```
- `git -C "D:\Game\LifePlay-master 5.5" status --short` : 69 lignes, inchangées par cette validation (retraits indexés `D ` de `ModSources/`, `AnimSources/`, `Tools/BuildMod.ps1`, `NewMod.ps1`, `EditMod.ps1`, `ModSource.ps1`, `HairPipeline.ps1`, `Tools/anim/*`, `Tools/hairs`, `Tools/mods`, `Tools/debug/*_sandbox.bat`, `Config/LPSandbox.ini`, `Docs/Modding/...` ; modifiés ` M` `.gitignore`, `README.md`, `Docs/Modding/ShortGuides/explanation.lpmod`, `Source/LifePlay/LPModInfoSubsystem.h`, `Tools/BuildDebug.ps1`, `Tools/SplitModules.ps1` ; non suivis `.cursor/`, `Docs/Migration/etat/PUB1/`, `Docs/Migration/etat/commit_plan/CP-RUN.md`, `Docs/Migration/etat/sandbox/DC3/`, `LPSandboxMeasure.cpp`, `LPSandboxMeasureTour.cpp`).
## Anomalies
1. `Tools\RunCommandlet.ps1` écrit son résumé `D:\Game\LifePlay-master 5.5\Docs\Migration\etat\DC3_LPAnimCatalog_20261007-194818.txt` (comportement du script, non listé par git status, sans doute ignoré) : à déplacer ou ignorer si indésirable.
2. `Saved\sandbox_mode.txt` contient `Pair` en fin de validation (valeur écrite par le lanceur pair en V8 ; la valeur d'origine avant V6 n'avait pas été relevée, le pilote la restaure lui-même après V6).
3. Compte des entrées de zip : 41 entrées dont dossiers pour 29 fichiers (la fiche ne précisait pas la différence) ; sans conséquence.
4. Le dossier `D:\Game\LifePlay_Backup_5.5\DC3-VAL\x\` contient les deux extractions de zip (rien supprimé) ; peut être effacé par l'utilisateur.
5. Aucune anomalie sur les chemins : l'addon est utilisé seul pour profils/méthodes (V6, V5 réussis sans repli), et le jeu n'a plus aucun des éléments d'atelier.
## Verdict
**PASS** sur V0–V9. Critères : V4 zip produit avec la même liste de fichiers (et contenus octet pour octet identiques) ; V6 73 fichiers comparés ; V7 code 1 sans écriture ; `<jeu>\Mods` vide à la fin.
## Reste à valider par l'utilisateur
Rien de visuel, sauf s'il souhaite ouvrir EditMod (`D:\Game\LifePlay-Sandbox\Tools\EditMod.ps1`, lancement de l'éditeur sur un mod, non testé ici).
## Modèles par fiche
| Fiche | Modèle prévu | Modèle utilisé |
|---|---|---|
| DC3-T1 | opus | opus |
| DC3-T2 | opus | opus |
| DC3-T3a | haiku | haiku |
| DC3-T3b | sonnet | sonnet |

View file

@ -8,9 +8,9 @@ Date : 2026-10-06 — Role A-INT
|---|---|
| Repo addon | https://github.com/edujiro5972/LifePlay-Sandbox.git |
| Branche | `main` |
| **Commit pin** | `97c0d134cd1388c3735add79d77ea98817e89fbe` (`97c0d13`, DC2 : module editeur `LPModTools` et chemins propres a l'addon, branche `lot/DC2-modtools`, tag `lot-DC2-valide`, **local, non pousse** au 2026-10-07) |
| **Commit pin** | `79f0205b7281152dde7cd088086d8c813e0b20f5` (`79f0205`, DC3 : atelier de mods dans l'addon, replis vers le jeu retires, branche `lot/DC3-atelier`, tag `lot-DC3-valide`, **local, non pousse** au 2026-10-07) |
| Contenu du commit | S5-T2 : `LPCoupleSim` (géométrie du couple partagée jeu/addon) ; inclut S4-T3 (retrait GM Interaction legacy), S4-T2 (découpe `LPPairSandbox.cpp`) et S3-T4 (`StartupModule` → `SetUALRoot`) |
| Pins précédents | `3e9c32f1f27b1abdaf7731dc90496c27ee061718` (DC1), `16905c68070992f5139dab907b41b461fb765957` (SB4), `3d858527fccfdddd8711c7c7448fa001442e0b8a` (SB2-O1), `82dae58380b76c45f60541fd7cb5a0a409700e10` (SB1), `f09c42e5a2bc613cf269cce76c878461ca41c8b7` (S7-T2), `c1fdd29e2c1036c7d6365ba6389eff5d2f8c8c96` (S5-T2), `f8102647ccdebad37e4977ccbf2c51e583823a80` (S4-T3), `4de1f0b6764ed3bc575bb687630d3f070e1be221` (S3-T4) |
| Pins précédents | `97c0d134cd1388c3735add79d77ea98817e89fbe` (DC2), `3e9c32f1f27b1abdaf7731dc90496c27ee061718` (DC1), `16905c68070992f5139dab907b41b461fb765957` (SB4), `3d858527fccfdddd8711c7c7448fa001442e0b8a` (SB2-O1), `82dae58380b76c45f60541fd7cb5a0a409700e10` (SB1), `f09c42e5a2bc613cf269cce76c878461ca41c8b7` (S7-T2), `c1fdd29e2c1036c7d6365ba6389eff5d2f8c8c96` (S5-T2), `f8102647ccdebad37e4977ccbf2c51e583823a80` (S4-T3), `4de1f0b6764ed3bc575bb687630d3f070e1be221` (S3-T4) |
## Structure
@ -46,7 +46,7 @@ sur la copie effectivement chargee. Pin manuel a la place (ci-dessous).
```powershell
cd D:\Game
git clone https://github.com/edujiro5972/LifePlay-Sandbox.git
git -C LifePlay-Sandbox checkout 97c0d134cd1388c3735add79d77ea98817e89fbe
git -C LifePlay-Sandbox checkout 79f0205b7281152dde7cd088086d8c813e0b20f5
# Le jeu doit etre clone a cote : D:\Game\LifePlay-master 5.5
```

View file

@ -1,177 +0,0 @@
# Creating asset mods (hairstyles, and more)
*LifePlay Revival addition (UE 5.5). Available from the first release published with its reference version (`Releases/<version>` in the full source archive), that is the release after LifePlay 2026.10.4 (V1.4).*
Text mods (scenes, actions, stats, presets...) work as before: a folder in `LifePlay/Content/Modules`, see
[Getting_Started.txt](Getting_Started.txt). This guide is about mods that bring **new 3D assets** (meshes, textures,
materials) without rebuilding the game. The first supported use is **hairstyles**.
## 1. How it works
An asset mod has two parts, installed side by side in the game folder:
```
LifePlay/Mods/<Name>/<Name>.uplugin plugin descriptor
LifePlay/Mods/<Name>/Content/Paks/Windows/<Name>LifePlay-Windows.pak/.utoc/.ucas cooked assets
LifePlay/Content/Modules/<Name>/<Name>.lpmod LifePlay module (text, as usual)
LifePlay/Content/Modules/<Name>/Hairstyles/*.lphair hairstyle declarations
LifePlay/Content/Modules/<Name>/CREDITS.txt credits and licenses
```
- The engine mounts every plugin found in `LifePlay/Mods/` at startup. Its assets live under `/<Name>/...`.
- The text module makes the mod visible in the **Mod Manager** (title screen, or Ctrl-M in game). The hairstyles are
available only while the module is enabled.
- Cooked assets are tied to a game version: a mod is built against the **reference version** of a release
(`Releases/<version>`, shipped with the full source archive) and is made for that version. Another game version
may require rebuilding the mod.
## 2. Requirements
- Windows, Unreal Engine **5.5** (Epic Games Launcher), Visual Studio 2022 is not needed for asset mods.
- The **LifePlay Revival full source archive** of the game version you target, including `Releases/<version>/`
(for example `Releases/2026.10.4/` for LifePlay 2026.10.4). Open `LifePlay.uproject` once to let the editor build
its caches.
- 7-Zip (`C:\Program Files\7-Zip\7z.exe`) to package the mod.
- Assets you are **allowed to redistribute**: CC0 or CC BY (credit the author). Store licenses such as the Fab
"Standard License" forbid redistributing the source files and are not suitable.
The scripts below are in `Tools/` and are run from a PowerShell window in the project folder. The engine path
(`D:\Game\Launcher\Engine\UE_5.5`) is set at the top of each script: adjust it if your engine is elsewhere.
## 3. Create the mod
```powershell
.\Tools\NewMod.ps1 -Name MyHairPack -Title "My Hair Pack" -Author "Me" -Hairs
```
This creates the mod sources in `ModSources/MyHairPack/`:
```
ModSources/MyHairPack/MyHairPack.uplugin content plugin
ModSources/MyHairPack/Content/ your assets (filled by the editor or the hair pipeline)
ModSources/MyHairPack/Module/MyHairPack.lpmod
ModSources/MyHairPack/Module/CREDITS.txt
Tools/mods/MyHairPack.json hair pipeline configuration (with -Hairs)
```
Mod sources stay in `ModSources/`. The tools move a mod into the project's `Mods/` folder only while they work on it
(import, cook) and move it back afterwards: a plugin left in `Mods/` would be cooked into the base game.
The name is the plugin name, the asset root (`/MyHairPack/`) and the module ID: letters, digits and underscores,
starting with a letter, unique.
## 4. Add hairstyles
Hairstyles must be skinned to the Genesis 8 skeleton used by the game characters and use the game's hair material.
The hair pipeline does this for static hairstyles distributed as FBX (for example the free Pocolov hairs on Fab):
it fits the hair to the default head, transfers skin weights from the body (head, neck, chest) so long hair follows
the character without cutting through the skin, and creates `DazHair` material instances.
1. Download the hairstyle archives (zip/rar/7z with an FBX and its textures).
2. Add one entry per hairstyle in `Tools/mods/MyHairPack.json` (see `Tools/mods/lpr_hair_pocolov.json`, the 12 Pocolov
hairstyles, for a complete example):
```json
{
"name": "MyHair01",
"archive": "D:/Downloads/my-hair-01.zip",
"title": "My Hair 01",
"url": "https://source/of/the/asset",
"author": "Original author",
"license": "CC BY 4.0",
"sex": "F",
"mesh": "auto", "head": "auto", "cap_slots": "auto", "diffuse": "auto", "strands_opacity": "auto",
"cap_opacity": "white",
"thicken": 1
}
```
3. Inspect, read the report, fix the JSON if a guess is wrong, then apply:
```powershell
.\Tools\HairPipeline.ps1 -Config Tools\mods\MyHairPack.json -Step Inspect
.\Tools\HairPipeline.ps1 -Config Tools\mods\MyHairPack.json -Step Apply
```
`Apply` writes the assets to `ModSources/MyHairPack/Content/Hairs/`, one `.lphair` per hairstyle in
`Module/Hairstyles/` and one line per hairstyle in `Module/CREDITS.txt`. To redo a hairstyle: `-Step Inspect` then
`-Step Apply -Rebuild -Only MyHair01` (the previous version is moved to the backup folder, never deleted).
Fitting options per hairstyle (`margin`, `scale`, `offset_z`, `offset_y`, `skin`, `gap`) and the detailed protocol
are described (in French) in `Docs/Migration/13_Protocole_Coiffure.md`.
A `.lphair` file can also be written by hand for a hairstyle made another way (see
[explanation.lphair](../ShortGuides/explanation.lphair)):
```
HAIRSTYLE: /MyHairPack/Hairs/MyHair01.MyHair01
SEX: F
DAZ: true
```
## 4b. Mod metadata
The first three lines of `<Name>.lpmod` are read **by position** by the game and must stay first, in this order:
`MODULE_UNIQUEID`, `MODULE_NAME`, `MODULE_AUTHOR`. The other lines are free; the Mod Manager shows the whole file in its
detail panel, so every line below is visible to players.
| Key | Written by | Meaning |
|---|---|---|
| `MODULE_VERSION` | you (`NewMod.ps1 -ModVersion`, default 1.0) | version of the mod; also in the archive name and the plugin `VersionName` |
| `MODULE_CREATED` | `NewMod.ps1` | creation date (YYYY-MM-DD) |
| `MODULE_LICENSE` | you (`-License`) | license of the mod as a whole (each asset's license stays in `CREDITS.txt`) |
| `MODULE_AUTHOR_LINK` | you (`-AuthorLink`) | author page; also the plugin `CreatedByURL` |
| `MODULE_PRICE` | you (`-Price`, default Free) | information only, the game sells nothing. A non-free mod is refused by `BuildMod.ps1` if `CREDITS.txt` mentions a non-commercial or store license (`NC`, `Standard License`, `Personal`) |
| `MODULE_DEFAULT_ENABLED` | you | `true`: enabled the first time the game finds it; otherwise a new mod starts disabled |
| `MODULE_PACKAGED` | `BuildMod.ps1` (shipped copy only) | packaging date |
| `MODULE_GAME_VERSION` | `BuildMod.ps1` (shipped copy only) | game version the assets were cooked for. If an **enabled** mod declares another version than the game, the game shows a "Mod version warning" window (once per session) |
## 5. Edit the mod in the editor
```powershell
.\Tools\EditMod.ps1 -Name MyHairPack
```
Opens the editor with the mod mounted: its assets appear in the Content Browser under **MyHairPack Content**
(`/MyHairPack/`). Plugin content is hidden by default: in the Content Browser
settings (gear icon), tick **Show Plugin Content**. The search box matches asset names, not folder names (search
`MyHair`, not `MyHairPack`). Close the editor to move the mod back to `ModSources/`. Keep every asset of the mod inside
`/MyHairPack/`; referencing game assets (skeletons, the `DazHair` material...) is fine, modifying them is not
(changes to game assets are not shipped by the mod).
## 6. Build and test
```powershell
.\Tools\BuildMod.ps1 -Name MyHairPack -Install <test game folder>\Windows
```
`BuildMod.ps1`:
1. checks the plugin, the module and the reference version (`Releases/<version>`, version read from
`Config/DefaultGame.ini`, or `-Version`);
2. cooks only the mod against the reference version (log in `Saved/Logs/portage/mods/`);
3. checks that the cooked container holds nothing outside `Mods/MyHairPack/` (no game asset is shipped);
4. builds `MyHairPack-for-LifePlay-<version>.zip` (with an install README) in `D:\Game\LifePlay_Publication\Mods`
(`-Out` to change it);
5. with `-Install`, copies the mod into a test game (the folder that contains `LifePlay.exe`).
Then start the game, open **Mod Manager** on the title screen, enable the mod, close the manager and start or load a
game: the new hairstyles are at the end of the hairstyle list (for the sex declared in `SEX`).
## 7. Distribute
Share the zip. Players extract it into the game folder (the one that contains `LifePlay.exe`), merging the `LifePlay`
folder, then enable the mod in the Mod Manager. To uninstall, they delete `LifePlay/Mods/<Name>` and
`LifePlay/Content/Modules/<Name>`. Always say which LifePlay version the mod was built for, and keep the credits of
every asset in `CREDITS.txt`.
## 8. Troubleshooting
| Symptom | Cause, fix |
|---|---|
| The mod is not listed in the Mod Manager | `LifePlay/Content/Modules/<Name>/<Name>.lpmod` missing, or `MODULE_UNIQUEID` different from the folder name |
| The mod is listed but the hairstyle does not appear | module not enabled; `HAIRSTYLE` path wrong (`/<Name>/Hairs/<Asset>.<Asset>`); `LifePlay/Mods/<Name>/Content/Paks/Windows` missing; wrong `SEX` |
| `Mods\<Name> existe deja` when running a tool | a previous run was interrupted: move `Mods/<Name>` back to `ModSources/<Name>` by hand |
| `Version de reference absente` | the source archive lacks `Releases/<version>`: use the source archive of the targeted game version |
| `Autres plugins dans Mods\` | another mod is still mounted: move it back to `ModSources/` |
| The hairstyle cuts through the shoulders or floats | adjust `gap`, `margin`, `offset_z`, `offset_y` and rebuild the hairstyle |

View file

@ -1,18 +0,0 @@
LifePlay Revival addition. Lphair files declare hairstyles, beards and body hair. Put them anywhere inside your mod folder (for example Content\Modules\<your mod>\Hairstyles\). They are read at startup; the hairstyles of a mod are available only while the mod is enabled in the Mod Manager.
HAIRSTYLE: /MyHairPack/Hairs/MyHair01.MyHair01 // full path of a skeletal mesh skinned to the Genesis 8 skeleton (an asset of the game, or of an asset mod: see Docs/Modding/FullGuides/Creating_Asset_Mods.md), or NONE for "no hair"
SEX: F // F or M (default F): which characters can use it
DAZ: true // true for hairstyles made for the Daz Genesis 8 characters (all current ones)
FIT_MORPH: MorphName 0.5 // optional: morph target applied to the hair, with its value, to fit the head
The // comments at the end of the lines above are explanations only: in a real file, a comment must be on its own line.
Several hairstyles can be declared in one file: each HAIRSTYLE line starts a new hairstyle, the SEX, DAZ and FIT_MORPH lines that follow apply to it. Lines starting with // are comments.
Beards and body hair are textures painted on the skin, listed by name (the texture must exist in the game folder used by the game for that list):
FACIAL_HAIR: Facial_Hair__1_ // beard (male)
BODY_HAIR_M: JPBH78-TorsoNG-01 // male body hair
BODY_HAIR_F: Pubes01 // female body hair
The base game list is Content\Modules\vin_Base\Hairstyles\vin_Base.lphair (MODE: BASE): do not copy that line into your own files.

View file

@ -14,5 +14,5 @@ LifePlay Revival addition:
MODULE_DEFAULT_ENABLED: true // optional: the mod is enabled the first time the game finds it (otherwise a new mod starts disabled until the player ticks it in the Mod Manager). Used by lpr_hair_pocolov.
The first three lines (MODULE_UNIQUEID, MODULE_NAME, MODULE_AUTHOR) are read by position: keep them first, in this order.
Other optional keys, shown as-is in the Mod Manager: MODULE_VERSION, MODULE_CREATED, MODULE_LICENSE, MODULE_AUTHOR_LINK, MODULE_PRICE,
MODULE_PACKAGED and MODULE_GAME_VERSION (written by Tools/BuildMod.ps1; the game warns when an enabled mod was built for another
MODULE_PACKAGED and MODULE_GAME_VERSION (written by BuildMod.ps1 of the mod workshop, a separate repository; the game warns when an enabled mod was built for another
game version). See Docs/Modding/FullGuides/Creating_Asset_Mods.md, section 4b.

File diff suppressed because one or more lines are too long

View file

@ -1,761 +0,0 @@
{
"pack": "freeanims",
"mod": "lpr_anim_freeanims",
"version": "1.0",
"genere": "2026-10-07T02:38:49",
"nombre_animations": 10,
"credit": "Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay",
"animations": [
{
"cle": "fap_as_airkiss",
"appel": {
"script": "Actor.animate(fap_as_airkiss)",
"action": "ANIMATION: fap_as_airkiss"
},
"nom": {
"fr": "Baiser envoyé",
"en": "Blow a kiss"
},
"assets": {
"feminin": "/lpr_anim_freeanims/Anims/Female/fap_as_airkiss_F",
"masculin": "/lpr_anim_freeanims/Anims/Male/fap_as_airkiss_M"
},
"duree_s": 10.3,
"images": 310,
"planche": {
"fichier": "planches/fap_as_airkiss.jpg",
"images": 24,
"taille": 256,
"ips": 2.3
},
"cadence": 30,
"taille_ko": {
"feminin": 1262,
"masculin": 1253,
"total": 2515
},
"taille_cuite": false,
"boucle": {
"oui": false,
"ecart_pct": 36.57
},
"deplacement": {
"cm": 8,
"cm_s": 1,
"sens": "cote"
},
"posture": {
"principale": "debout",
"posture_origine": "calculee"
},
"contacts": {
"pieds_au_sol_pct": {
"gauche": 100,
"droite": 100
},
"mains": {
"gauche": "repos",
"droite": "mouvement"
}
},
"accessoire": "",
"categorie": "interaction",
"style": "détendu",
"usage": {
"fr": "Geste ponctuel (probablement joué une fois) d'envoi de baiser, joué sur place ; utile pour une scène de flirt ou d'adieu.",
"en": "One-shot gesture (probably played once) of blowing a kiss, played in place; useful for a flirting or farewell scene."
},
"corps": [
"feminin",
"masculin"
],
"qualite": {
"verdict": "accepté",
"motifs": [],
"pire_brut_pct": 0.0,
"pire_torsion_deg": 0.0
},
"source": "Free Animations Pack (SoerGame, Fab ; mannequin UE5)",
"licence": "CC BY 4.0",
"credit": "Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay",
"version": "1.0",
"empreinte": "FFBE444E7D7717C9DFD6FD4E3BD7DD706F8BB600E8AB05239CBEF9078652693A"
},
{
"cle": "fap_as_attackinlevitation2",
"appel": {
"script": "Actor.animate(fap_as_attackinlevitation2)",
"action": "ANIMATION: fap_as_attackinlevitation2"
},
"nom": {
"fr": "Attaque en lévitation",
"en": "Levitation attack"
},
"assets": {
"feminin": "/lpr_anim_freeanims/Anims/Female/fap_as_attackinlevitation2_F",
"masculin": "/lpr_anim_freeanims/Anims/Male/fap_as_attackinlevitation2_M"
},
"duree_s": 7.07,
"images": 213,
"planche": {
"fichier": "planches/fap_as_attackinlevitation2.jpg",
"images": 24,
"taille": 256,
"ips": 3.4
},
"cadence": 30,
"taille_ko": {
"feminin": 944,
"masculin": 934,
"total": 1878
},
"taille_cuite": false,
"boucle": {
"oui": false,
"ecart_pct": 6.74
},
"deplacement": {
"cm": 6,
"cm_s": 1,
"sens": "cote"
},
"posture": {
"principale": "debout",
"posture_origine": "calculee"
},
"contacts": {
"pieds_au_sol_pct": {
"gauche": 0,
"droite": 0
},
"mains": {
"gauche": "mouvement",
"droite": "levee"
}
},
"accessoire": "",
"categorie": "magie",
"style": "agressif",
"usage": {
"fr": "Attaque lancée en lévitation, probablement jouée une fois, sur place ; pour un sort ou une attaque surnaturelle.",
"en": "Attack performed while levitating, probably played once, in place; for a spell or supernatural attack."
},
"corps": [
"feminin",
"masculin"
],
"qualite": {
"verdict": "accepté",
"motifs": [],
"pire_brut_pct": 0.0,
"pire_torsion_deg": 0.01
},
"source": "Free Animations Pack (SoerGame, Fab ; mannequin UE5)",
"licence": "CC BY 4.0",
"credit": "Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay",
"version": "1.0",
"empreinte": "D909B0E97115A819BCCA61CC8B1D4EE819DB91CF6D471EFCF8E87C6987E8D0F1"
},
{
"cle": "fap_as_chilldriving",
"appel": {
"script": "Actor.animate(fap_as_chilldriving)",
"action": "ANIMATION: fap_as_chilldriving"
},
"nom": {
"fr": "Conduite détendue",
"en": "Relaxed driving"
},
"assets": {
"feminin": "/lpr_anim_freeanims/Anims/Female/fap_as_chilldriving_F",
"masculin": "/lpr_anim_freeanims/Anims/Male/fap_as_chilldriving_M"
},
"duree_s": 6.7,
"images": 202,
"planche": {
"fichier": "planches/fap_as_chilldriving.jpg",
"images": 24,
"taille": 256,
"ips": 3.6
},
"cadence": 30,
"taille_ko": {
"feminin": 908,
"masculin": 898,
"total": 1806
},
"taille_cuite": false,
"boucle": {
"oui": false,
"ecart_pct": 3.88
},
"deplacement": {
"cm": 1,
"cm_s": 0,
"sens": "sur place"
},
"posture": {
"principale": "assis",
"posture_origine": "calculee",
"hauteur_assise_cm": 40
},
"contacts": {
"pieds_au_sol_pct": {
"gauche": 100,
"droite": 100
},
"mains": {
"gauche": "repos",
"droite": "levee"
}
},
"accessoire": "volant",
"categorie": "conduite",
"style": "détendu",
"usage": {
"fr": "Pose de conduite détendue, probablement en boucle, jouée sur place ; à utiliser avec un véhicule ou un volant.",
"en": "Relaxed driving pose, probably looping, played in place; to use with a vehicle or steering wheel."
},
"corps": [
"feminin",
"masculin"
],
"qualite": {
"verdict": "accepté",
"motifs": [],
"pire_brut_pct": 0.0,
"pire_torsion_deg": 0.0
},
"source": "Free Animations Pack (SoerGame, Fab ; mannequin UE5)",
"licence": "CC BY 4.0",
"credit": "Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay",
"version": "1.0",
"empreinte": "5A74F7DA765AB938991A14FFF3E77DB6A18C12D025D810D598DBDD936EB67E24"
},
{
"cle": "fap_as_combohands",
"appel": {
"script": "Actor.animate(fap_as_combohands)",
"action": "ANIMATION: fap_as_combohands"
},
"nom": {
"fr": "Enchaînement de poings",
"en": "Punch combo"
},
"assets": {
"feminin": "/lpr_anim_freeanims/Anims/Female/fap_as_combohands_F",
"masculin": "/lpr_anim_freeanims/Anims/Male/fap_as_combohands_M"
},
"duree_s": 2.2,
"images": 67,
"planche": {
"fichier": "planches/fap_as_combohands.jpg",
"images": 24,
"taille": 256,
"ips": 10.9
},
"cadence": 30,
"taille_ko": {
"feminin": 517,
"masculin": 507,
"total": 1025
},
"taille_cuite": false,
"boucle": {
"oui": false,
"ecart_pct": 56.24
},
"deplacement": {
"cm": 2,
"cm_s": 1,
"sens": "sur place"
},
"posture": {
"principale": "debout",
"posture_origine": "calculee"
},
"contacts": {
"pieds_au_sol_pct": {
"gauche": 100,
"droite": 93
},
"mains": {
"gauche": "mouvement",
"droite": "mouvement"
}
},
"accessoire": "",
"categorie": "combat",
"style": "agressif",
"usage": {
"fr": "Enchaînement de coups de poing, probablement joué une fois, sur place ; pour une bagarre ou un entraînement.",
"en": "Sequence of punches, probably played once, in place; for a fight or training scene."
},
"corps": [
"feminin",
"masculin"
],
"qualite": {
"verdict": "accepté",
"motifs": [],
"pire_brut_pct": 0.0,
"pire_torsion_deg": 0.0
},
"source": "Free Animations Pack (SoerGame, Fab ; mannequin UE5)",
"licence": "CC BY 4.0",
"credit": "Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay",
"version": "1.0",
"empreinte": "012073AFF1B80B99F9C15CBAA1BA428CCCB1B16C3C21746E3182956A4BE967A4"
},
{
"cle": "fap_as_eatingonparty",
"appel": {
"script": "Actor.animate(fap_as_eatingonparty)",
"action": "ANIMATION: fap_as_eatingonparty"
},
"nom": {
"fr": "Manger en soirée",
"en": "Eating at party"
},
"assets": {
"feminin": "/lpr_anim_freeanims/Anims/Female/fap_as_eatingonparty_F",
"masculin": "/lpr_anim_freeanims/Anims/Male/fap_as_eatingonparty_M"
},
"duree_s": 23.7,
"images": 712,
"planche": {
"fichier": "planches/fap_as_eatingonparty.jpg",
"images": 24,
"taille": 256,
"ips": 1.0
},
"cadence": 30,
"taille_ko": {
"feminin": 2581,
"masculin": 2571,
"total": 5152
},
"taille_cuite": false,
"boucle": {
"oui": false,
"ecart_pct": 19.28
},
"deplacement": {
"cm": 10,
"cm_s": 0,
"sens": "cote"
},
"posture": {
"principale": "debout",
"posture_origine": "calculee"
},
"contacts": {
"pieds_au_sol_pct": {
"gauche": 100,
"droite": 100
},
"mains": {
"gauche": "repos",
"droite": "mouvement"
}
},
"accessoire": "",
"categorie": "interaction",
"style": "détendu",
"usage": {
"fr": "Personnage mangeant debout lors d'une soirée, probablement en boucle, joué sur place ; pour un buffet ou une fête.",
"en": "Character eating while standing at a party, probably looping, played in place; for a buffet or party scene."
},
"corps": [
"feminin",
"masculin"
],
"qualite": {
"verdict": "accepté",
"motifs": [],
"pire_brut_pct": 0.0,
"pire_torsion_deg": 0.0
},
"source": "Free Animations Pack (SoerGame, Fab ; mannequin UE5)",
"licence": "CC BY 4.0",
"credit": "Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay",
"version": "1.0",
"empreinte": "8D06644A66882A9CD6A5D9CF112353409FADAF453A397DBBAC99E177C0753FBA"
},
{
"cle": "fap_as_flyingup",
"appel": {
"script": "Actor.animate(fap_as_flyingup)",
"action": "ANIMATION: fap_as_flyingup"
},
"nom": {
"fr": "Envol",
"en": "Take off"
},
"assets": {
"feminin": "/lpr_anim_freeanims/Anims/Female/fap_as_flyingup_F",
"masculin": "/lpr_anim_freeanims/Anims/Male/fap_as_flyingup_M"
},
"duree_s": 2.7,
"images": 82,
"planche": {
"fichier": "planches/fap_as_flyingup.jpg",
"images": 24,
"taille": 256,
"ips": 8.9
},
"cadence": 30,
"taille_ko": {
"feminin": 514,
"masculin": 504,
"total": 1018
},
"taille_cuite": false,
"boucle": {
"oui": false,
"ecart_pct": 68.13
},
"deplacement": {
"cm": 2,
"cm_s": 1,
"sens": "sur place"
},
"posture": {
"principale": "debout",
"posture_origine": "calculee"
},
"contacts": {
"pieds_au_sol_pct": {
"gauche": 43,
"droite": 43
},
"mains": {
"gauche": "levee",
"droite": "levee"
}
},
"accessoire": "",
"categorie": "magie",
"style": "neutre",
"usage": {
"fr": "Décollage vers le haut, probablement joué une fois, sur place ; pour un envol ou une lévitation.",
"en": "Upward take-off, probably played once, in place; for flying off or levitating."
},
"corps": [
"feminin",
"masculin"
],
"qualite": {
"verdict": "accepté",
"motifs": [],
"pire_brut_pct": 0.0,
"pire_torsion_deg": 0.0
},
"source": "Free Animations Pack (SoerGame, Fab ; mannequin UE5)",
"licence": "CC BY 4.0",
"credit": "Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay",
"version": "1.0",
"empreinte": "327291EFF0B154B719CE85A07AD11B4DED45F4DB16787A5DE1BA3960092BA8C6"
},
{
"cle": "fap_as_goforward",
"appel": {
"script": "Actor.animate(fap_as_goforward)",
"action": "ANIMATION: fap_as_goforward"
},
"nom": {
"fr": "Marche avant",
"en": "Walk forward"
},
"assets": {
"feminin": "/lpr_anim_freeanims/Anims/Female/fap_as_goforward_F",
"masculin": "/lpr_anim_freeanims/Anims/Male/fap_as_goforward_M"
},
"duree_s": 3.37,
"images": 102,
"planche": {
"fichier": "planches/fap_as_goforward.jpg",
"images": 24,
"taille": 256,
"ips": 7.1
},
"cadence": 30,
"taille_ko": {
"feminin": 579,
"masculin": 569,
"total": 1149
},
"taille_cuite": false,
"boucle": {
"oui": false,
"ecart_pct": 4.29
},
"deplacement": {
"cm": 0,
"cm_s": 0,
"sens": "sur place"
},
"posture": {
"principale": "debout",
"posture_origine": "calculee"
},
"contacts": {
"pieds_au_sol_pct": {
"gauche": 100,
"droite": 100
},
"mains": {
"gauche": "mouvement",
"droite": "levee"
}
},
"accessoire": "",
"categorie": "locomotion",
"style": "neutre",
"usage": {
"fr": "Cycle de marche vers l'avant, probablement en boucle, joué sur place ; à combiner avec un déplacement du personnage.",
"en": "Forward walk cycle, probably looping, played in place; combine with a character movement."
},
"corps": [
"feminin",
"masculin"
],
"qualite": {
"verdict": "accepté",
"motifs": [],
"pire_brut_pct": 0.0,
"pire_torsion_deg": 0.0
},
"source": "Free Animations Pack (SoerGame, Fab ; mannequin UE5)",
"licence": "CC BY 4.0",
"credit": "Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay",
"version": "1.0",
"empreinte": "44363245CBE7C209178B3292D49B97B43F2DF0DBCC4FBC5B58EA230B8C48E469"
},
{
"cle": "fap_as_rest",
"appel": {
"script": "Actor.animate(fap_as_rest)",
"action": "ANIMATION: fap_as_rest"
},
"nom": {
"fr": "Repos",
"en": "Rest"
},
"assets": {
"feminin": "/lpr_anim_freeanims/Anims/Female/fap_as_rest_F",
"masculin": "/lpr_anim_freeanims/Anims/Male/fap_as_rest_M"
},
"duree_s": 11.47,
"images": 345,
"planche": {
"fichier": "planches/fap_as_rest.jpg",
"images": 24,
"taille": 256,
"ips": 2.1
},
"cadence": 30,
"taille_ko": {
"feminin": 1377,
"masculin": 1357,
"total": 2734
},
"taille_cuite": false,
"boucle": {
"oui": false,
"ecart_pct": 4.26
},
"deplacement": {
"cm": 1,
"cm_s": 0,
"sens": "sur place"
},
"posture": {
"principale": "allonge",
"posture_origine": "calculee"
},
"contacts": {
"pieds_au_sol_pct": {
"gauche": 100,
"droite": 100
},
"mains": {
"gauche": "repos",
"droite": "repos"
}
},
"accessoire": "",
"categorie": "attente",
"style": "neutre",
"usage": {
"fr": "Posture de repos debout, probablement en boucle, jouée sur place ; pose d'attente par défaut.",
"en": "Standing rest posture, probably looping, played in place; default waiting pose."
},
"corps": [
"feminin",
"masculin"
],
"qualite": {
"verdict": "accepté",
"motifs": [],
"pire_brut_pct": 0.0,
"pire_torsion_deg": 0.0
},
"source": "Free Animations Pack (SoerGame, Fab ; mannequin UE5)",
"licence": "CC BY 4.0",
"credit": "Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay",
"version": "1.0",
"empreinte": "513BEEE6DC9431E52D66DC07D26C88621C6A6DFF465DEF42F81E711E2A5AE3DF"
},
{
"cle": "fap_as_rightlegkick2",
"appel": {
"script": "Actor.animate(fap_as_rightlegkick2)",
"action": "ANIMATION: fap_as_rightlegkick2"
},
"nom": {
"fr": "Coup de pied droit",
"en": "Right leg kick"
},
"assets": {
"feminin": "/lpr_anim_freeanims/Anims/Female/fap_as_rightlegkick2_F",
"masculin": "/lpr_anim_freeanims/Anims/Male/fap_as_rightlegkick2_M"
},
"duree_s": 1.1,
"images": 34,
"planche": {
"fichier": "planches/fap_as_rightlegkick2.jpg",
"images": 13,
"taille": 256,
"ips": 11.8
},
"cadence": 30,
"taille_ko": {
"feminin": 387,
"masculin": 377,
"total": 764
},
"taille_cuite": false,
"boucle": {
"oui": false,
"ecart_pct": 55.01
},
"deplacement": {
"cm": 11,
"cm_s": 10,
"sens": "avant"
},
"posture": {
"principale": "debout",
"posture_origine": "calculee"
},
"contacts": {
"pieds_au_sol_pct": {
"gauche": 0,
"droite": 38
},
"mains": {
"gauche": "mouvement",
"droite": "mouvement"
}
},
"accessoire": "",
"categorie": "combat",
"style": "agressif",
"usage": {
"fr": "Coup de pied de la jambe droite, probablement joué une fois, sur place ; pour un combat ou un entraînement.",
"en": "Right leg kick, probably played once, in place; for a fight or training scene."
},
"corps": [
"feminin",
"masculin"
],
"qualite": {
"verdict": "accepté",
"motifs": [],
"pire_brut_pct": 0.0,
"pire_torsion_deg": 0.0
},
"source": "Free Animations Pack (SoerGame, Fab ; mannequin UE5)",
"licence": "CC BY 4.0",
"credit": "Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay",
"version": "1.0",
"empreinte": "FA818881DD479BA94192C8C1BA8A8220E0E2D94432D1E04E12E59F998F0129F0"
},
{
"cle": "fap_as_talk",
"appel": {
"script": "Actor.animate(fap_as_talk)",
"action": "ANIMATION: fap_as_talk"
},
"nom": {
"fr": "Parler",
"en": "Talk"
},
"assets": {
"feminin": "/lpr_anim_freeanims/Anims/Female/fap_as_talk_F",
"masculin": "/lpr_anim_freeanims/Anims/Male/fap_as_talk_M"
},
"duree_s": 14.07,
"images": 423,
"planche": {
"fichier": "planches/fap_as_talk.jpg",
"images": 24,
"taille": 256,
"ips": 1.7
},
"cadence": 30,
"taille_ko": {
"feminin": 1633,
"masculin": 1623,
"total": 3256
},
"taille_cuite": false,
"boucle": {
"oui": false,
"ecart_pct": 28.35
},
"deplacement": {
"cm": 2,
"cm_s": 0,
"sens": "sur place"
},
"posture": {
"principale": "debout",
"posture_origine": "calculee"
},
"contacts": {
"pieds_au_sol_pct": {
"gauche": 100,
"droite": 100
},
"mains": {
"gauche": "mouvement",
"droite": "mouvement"
}
},
"accessoire": "",
"categorie": "conversation",
"style": "neutre",
"usage": {
"fr": "Personnage qui parle debout, probablement en boucle, joué sur place ; pour un dialogue ou une conversation.",
"en": "Character talking while standing, probably looping, played in place; for a dialogue or conversation."
},
"corps": [
"feminin",
"masculin"
],
"qualite": {
"verdict": "accepté",
"motifs": [],
"pire_brut_pct": 0.0,
"pire_torsion_deg": 0.01
},
"source": "Free Animations Pack (SoerGame, Fab ; mannequin UE5)",
"licence": "CC BY 4.0",
"credit": "Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay",
"version": "1.0",
"empreinte": "5C9CB1D57C27D542A2DC131F4C53EDA4171BD96F8FD5B9CD831566AA86563A75"
}
]
}

View file

@ -1,2 +0,0 @@
Credits and licenses of the assets in Free Animations Pack (SoerGame, Fab ; mannequin UE5) (one line per asset: title, author, license, source URL, changes).
- Animations : Free Animations Pack par SoerGame (Fab), licence Creative Commons Attribution (CC BY 4.0), converties pour LifePlay, https://www.fab.com/listings/9e93d929-36e8-45f9-b925-325db1b9385d . Changes: retargeted to the Genesis 8 skeletons (Bridget, Michael), renamed fap_<clip>_F / fap_<clip>_M.

View file

@ -1,12 +0,0 @@
MODULE_UNIQUEID: lpr_anim_freeanims
MODULE_NAME: Free Animations Pack (SoerGame, Fab ; mannequin UE5)
MODULE_AUTHOR: LifePlay Revival (animations by SoerGame)
MODULE_LINK: https://github.com/edujiro5972/LifePlay-Revival
MODULE_DESCRIPTION: Free animation clips from Free Animations Pack (SoerGame, Fab ; mannequin UE5) (CC BY 4.0), retargeted for LifePlay
MODULE_REQUIREMENTS: vin_Base
MODULE_VERSION: 1.0
MODULE_CREATED: 2026-10-07
MODULE_LICENSE: CC BY 4.0
MODULE_AUTHOR_LINK: https://www.fab.com/sellers/SoerGame
MODULE_PRICE: Free
MODULE_DEFAULT_ENABLED: true

View file

@ -1,13 +0,0 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0",
"FriendlyName": "Free Animations Pack (SoerGame, Fab ; mannequin UE5)",
"Description": "Free animation clips from Free Animations Pack (SoerGame, Fab ; mannequin UE5) (CC BY 4.0), retargeted for LifePlay",
"Category": "LifePlay Mods",
"CreatedBy": "LifePlay Revival (animations by SoerGame)",
"CanContainContent": true,
"IsBetaVersion": false,
"Installed": false,
"CreatedByURL": "https://www.fab.com/sellers/SoerGame"
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,2 +0,0 @@
Credits and licenses of the assets in Quaternius Animations (one line per asset: title, author, license, source URL, changes).
- Universal Animation Library by Quaternius, CC0 1.0, https://quaternius.com . Changes: retargeted to the Genesis 8 skeletons (Bridget, Michael), renamed qua_<clip>_F / qua_<clip>_M.

View file

@ -1,12 +0,0 @@
MODULE_UNIQUEID: lpr_anim_quaternius
MODULE_NAME: Quaternius Animations
MODULE_AUTHOR: LifePlay Revival (animations by Quaternius)
MODULE_LINK: https://github.com/edujiro5972/LifePlay-Revival
MODULE_DESCRIPTION: Free animation clips from the Quaternius Universal Animation Library (CC0), retargeted for LifePlay
MODULE_REQUIREMENTS: vin_Base
MODULE_VERSION: 1.0
MODULE_CREATED: 2026-10-06
MODULE_LICENSE: CC0
MODULE_AUTHOR_LINK: https://quaternius.com
MODULE_PRICE: Free
MODULE_DEFAULT_ENABLED: true

View file

@ -1,13 +0,0 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0",
"FriendlyName": "Quaternius Animations",
"Description": "Free animation clips from the Quaternius Universal Animation Library (CC0), retargeted for LifePlay",
"Category": "LifePlay Mods",
"CreatedBy": "LifePlay Revival (animations by Quaternius)",
"CanContainContent": true,
"IsBetaVersion": false,
"Installed": false,
"CreatedByURL": "https://quaternius.com"
}

View file

@ -1,13 +0,0 @@
Credits and licenses of the assets in Pocolov Hairstyles (one line per asset: title, author, license, source URL, changes).
- Pocolov Hair 03 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/2257098d-b77c-4eef-9132-85f1454f0ae3 . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials (/lpr_hair_pocolov/Hairs/Pocolov03*).
- Pocolov Hair 05 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/4025749f-d597-4332-91aa-16e355b94974 . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials (/lpr_hair_pocolov/Hairs/Pocolov05*).
- Pocolov Hair 06 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/14c3cd52-02f9-4bfe-bb40-b5c3acb1f428 . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials (/lpr_hair_pocolov/Hairs/Pocolov06*).
- Pocolov Hair 11 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/c31c219e-bc62-4957-91c5-720440375f79 . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials (/lpr_hair_pocolov/Hairs/Pocolov11*).
- Pocolov Hair 15 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/ed8e016a-0bf0-4a6c-94b0-2760083b879d . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials (/lpr_hair_pocolov/Hairs/Pocolov15*).
- Pocolov Hair 16 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/802dc956-6211-4b83-8ba1-37beb3cb338f . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials (/lpr_hair_pocolov/Hairs/Pocolov16*).
- Pocolov Hair 17 by Pocolov (Pocolov Studio 2019, artist Thang), CC BY 4.0, https://www.fab.com/listings/7f6f687d-bb13-40c6-8b69-64044ededc1d . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials (/lpr_hair_pocolov/Hairs/Pocolov17*).
- Pocolov Hair 07 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/4d1ba957-b420-4bdc-8984-b528a73b1d58 . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials (/lpr_hair_pocolov/Hairs/Pocolov07*).
- Pocolov Hair 09 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/80383c46-f491-4db2-a63d-48d279f35782 . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials (/lpr_hair_pocolov/Hairs/Pocolov09*).
- Pocolov Hair 10 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/40a91685-76dc-4384-91f1-0040f44e8958 . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials (/lpr_hair_pocolov/Hairs/Pocolov10*).
- Pocolov Hair 13 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/a3933681-f5e3-4d93-99f0-c5fff444d405 . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials (/lpr_hair_pocolov/Hairs/Pocolov13*).
- Pocolov Hair 14 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/4911f6b9-d548-45d7-86be-a6971da97c54 . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials (/lpr_hair_pocolov/Hairs/Pocolov14*).

View file

@ -1,6 +0,0 @@
// Pocolov Hair 03 - Pocolov (Pocolov Studio, artist Thang) - licence CC BY 4.0
// Source : https://www.fab.com/listings/2257098d-b77c-4eef-9132-85f1454f0ae3
// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.
HAIRSTYLE: /lpr_hair_pocolov/Hairs/Pocolov03.Pocolov03
SEX: F
DAZ: true

View file

@ -1,6 +0,0 @@
// Pocolov Hair 05 - Pocolov (Pocolov Studio, artist Thang) - licence CC BY 4.0
// Source : https://www.fab.com/listings/4025749f-d597-4332-91aa-16e355b94974
// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.
HAIRSTYLE: /lpr_hair_pocolov/Hairs/Pocolov05.Pocolov05
SEX: F
DAZ: true

View file

@ -1,6 +0,0 @@
// Pocolov Hair 06 - Pocolov (Pocolov Studio, artist Thang) - licence CC BY 4.0
// Source : https://www.fab.com/listings/14c3cd52-02f9-4bfe-bb40-b5c3acb1f428
// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.
HAIRSTYLE: /lpr_hair_pocolov/Hairs/Pocolov06.Pocolov06
SEX: F
DAZ: true

View file

@ -1,6 +0,0 @@
// Pocolov Hair 07 - Pocolov (Pocolov Studio, artist Thang) - licence CC BY 4.0
// Source : https://www.fab.com/listings/4d1ba957-b420-4bdc-8984-b528a73b1d58
// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.
HAIRSTYLE: /lpr_hair_pocolov/Hairs/Pocolov07.Pocolov07
SEX: F
DAZ: true

View file

@ -1,6 +0,0 @@
// Pocolov Hair 09 - Pocolov (Pocolov Studio, artist Thang) - licence CC BY 4.0
// Source : https://www.fab.com/listings/80383c46-f491-4db2-a63d-48d279f35782
// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.
HAIRSTYLE: /lpr_hair_pocolov/Hairs/Pocolov09.Pocolov09
SEX: F
DAZ: true

View file

@ -1,6 +0,0 @@
// Pocolov Hair 10 - Pocolov (Pocolov Studio, artist Thang) - licence CC BY 4.0
// Source : https://www.fab.com/listings/40a91685-76dc-4384-91f1-0040f44e8958
// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.
HAIRSTYLE: /lpr_hair_pocolov/Hairs/Pocolov10.Pocolov10
SEX: F
DAZ: true

View file

@ -1,6 +0,0 @@
// Pocolov Hair 11 - Pocolov (Pocolov Studio, artist Thang) - licence CC BY 4.0
// Source : https://www.fab.com/listings/c31c219e-bc62-4957-91c5-720440375f79
// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.
HAIRSTYLE: /lpr_hair_pocolov/Hairs/Pocolov11.Pocolov11
SEX: F
DAZ: true

View file

@ -1,6 +0,0 @@
// Pocolov Hair 13 - Pocolov (Pocolov Studio, artist Thang) - licence CC BY 4.0
// Source : https://www.fab.com/listings/a3933681-f5e3-4d93-99f0-c5fff444d405
// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.
HAIRSTYLE: /lpr_hair_pocolov/Hairs/Pocolov13.Pocolov13
SEX: F
DAZ: true

View file

@ -1,6 +0,0 @@
// Pocolov Hair 14 - Pocolov (Pocolov Studio, artist Thang) - licence CC BY 4.0
// Source : https://www.fab.com/listings/4911f6b9-d548-45d7-86be-a6971da97c54
// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.
HAIRSTYLE: /lpr_hair_pocolov/Hairs/Pocolov14.Pocolov14
SEX: F
DAZ: true

View file

@ -1,6 +0,0 @@
// Pocolov Hair 15 - Pocolov (Pocolov Studio, artist Thang) - licence CC BY 4.0
// Source : https://www.fab.com/listings/ed8e016a-0bf0-4a6c-94b0-2760083b879d
// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.
HAIRSTYLE: /lpr_hair_pocolov/Hairs/Pocolov15.Pocolov15
SEX: F
DAZ: true

View file

@ -1,6 +0,0 @@
// Pocolov Hair 16 - Pocolov (Pocolov Studio, artist Thang) - licence CC BY 4.0
// Source : https://www.fab.com/listings/802dc956-6211-4b83-8ba1-37beb3cb338f
// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.
HAIRSTYLE: /lpr_hair_pocolov/Hairs/Pocolov16.Pocolov16
SEX: F
DAZ: true

View file

@ -1,6 +0,0 @@
// Pocolov Hair 17 - Pocolov (Pocolov Studio 2019, artist Thang) - licence CC BY 4.0
// Source : https://www.fab.com/listings/7f6f687d-bb13-40c6-8b69-64044ededc1d
// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.
HAIRSTYLE: /lpr_hair_pocolov/Hairs/Pocolov17.Pocolov17
SEX: F
DAZ: true

View file

@ -1,12 +0,0 @@
MODULE_UNIQUEID: lpr_hair_pocolov
MODULE_NAME: Pocolov Hairstyles
MODULE_AUTHOR: LifePlay Revival (hairstyles by Pocolov)
MODULE_LINK: https://github.com/edujiro5972/LifePlay-Revival
MODULE_DESCRIPTION: 12 free hairstyles by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, converted for LifePlay
MODULE_REQUIREMENTS: vin_Base
MODULE_VERSION: 1.0
MODULE_CREATED: 2026-10-03
MODULE_LICENSE: CC BY 4.0
MODULE_AUTHOR_LINK: https://www.fab.com/sellers/Pocolov
MODULE_PRICE: Free
MODULE_DEFAULT_ENABLED: true

View file

@ -1,13 +0,0 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0",
"FriendlyName": "Pocolov Hairstyles",
"Description": "12 free hairstyles by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, converted for LifePlay",
"Category": "LifePlay Mods",
"CreatedBy": "LifePlay Revival (hairstyles by Pocolov)",
"CanContainContent": true,
"IsBetaVersion": false,
"Installed": false,
"CreatedByURL": "https://www.fab.com/sellers/Pocolov"
}

View file

@ -44,15 +44,15 @@ Mods can now bring their own 3D assets, and the Mod Manager is available from th
- **Asset mods.** A mod can ship cooked 3D assets (meshes, textures, materials) in `LifePlay/Mods/<mod>/` next to its
text module in `LifePlay/Content/Modules/<mod>/`, without rebuilding the game. Mods are cooked against the
**reference version** of the release (`Releases/2026.10.1.5`, included in the full source archive). Tools:
`Tools/NewMod.ps1`, `Tools/EditMod.ps1`, `Tools/HairPipeline.ps1`, `Tools/BuildMod.ps1`. Guide (English):
[`Docs/Modding/FullGuides/Creating_Asset_Mods.md`](Docs/Modding/FullGuides/Creating_Asset_Mods.md).
`NewMod.ps1`, `EditMod.ps1`, `HairPipeline.ps1`, `BuildMod.ps1` and the modding guide, which now live in a separate
mod workshop repository (not part of this repository; the game builds and runs without it).
- **Mod Manager on the title screen.** Choose your mods before starting or loading a game; the choice is saved. A mod
that the game does not know yet starts disabled (as in the original game), unless its `.lpmod` declares
`MODULE_DEFAULT_ENABLED: true`.
- **Mod metadata and version warning.** New optional `.lpmod` keys (version, dates, license, author link, price, game
version), shown in the Mod Manager; the game warns when an enabled mod was built for another game version.
- **Pocolov hairstyles as a mod.** The Pocolov hairstyles left the base game: they are now the asset mod
`lpr_hair_pocolov` (12 hairstyles, CC BY 4.0; sources in `ModSources/lpr_hair_pocolov`, credits in its
`lpr_hair_pocolov` (12 hairstyles, CC BY 4.0; sources in the mod workshop repository, credits in its
`CREDITS.txt`).
- **Game build.** The packaged game only contains the base modules `vin_Base` and `vin_VanillaRealistic`. The source
archive and this repository keep all modules.
@ -70,7 +70,7 @@ Moddable hairstyles, first new content, and a lighter game build. Verified in ga
- **6 new hairstyles** (Pocolov Hair 03, 06, 11, 15, 16, 17 — CC BY 4.0, credits in
`Licenses/Free_3D_Models_Credits.txt`), in the new `lp_Revival` module. They follow the head, neck and chest
(weights transferred from the body) and do not cut through the skin.
- **Hairstyle import pipeline** for developers: `Tools/HairPipeline.ps1` converts a static FBX hairstyle (Fab or
- **Hairstyle import pipeline** for developers (now in the separate mod workshop repository): `HairPipeline.ps1` converts a static FBX hairstyle (Fab or
elsewhere) in two commands; protocol in
[`Docs/Migration/13_Protocole_Coiffure.md`](Docs/Migration/13_Protocole_Coiffure.md) (French).
- **Main menu.** Original author credited (Vinfamy); Donate links to the LifePlay Revival Patreon; new Discord link;

View file

@ -5,7 +5,7 @@
// affiche le fichier entier. Cles ajoutees (facultatives, apres la ligne 3) :
// MODULE_VERSION, MODULE_CREATED, MODULE_PACKAGED (dates AAAA-MM-JJ), MODULE_LICENSE, MODULE_AUTHOR_LINK,
// MODULE_PRICE, MODULE_GAME_VERSION (version du jeu contre laquelle les assets du mod ont ete cuits, ecrite par
// Tools/BuildMod.ps1), MODULE_DEFAULT_ENABLED (lu par LPHairstyleSubsystem).
// LifePlay-Sandbox/Tools/BuildMod.ps1, atelier de mods de l'addon), MODULE_DEFAULT_ENABLED (lu par LPHairstyleSubsystem).
//
// Avertissement : un module ACTIF dont MODULE_GAME_VERSION differe de la version du jeu (ProjectVersion) declenche une
// fenetre (Slate, independante des Blueprints, aussi sur l'ecran titre), une fois par module et par session.

View file

@ -36,7 +36,7 @@ $buildFlag = ''
if (-not $SkipBuild) { $buildFlag = '-build ' }
# Pas de -nodebuginfo. -pak -iostore -compressed : reglages courants du projet (lot O2).
# Fichier .cmd : PowerShell couperait une ligne UAT mal quotee (meme parade que BuildMod.ps1).
# Fichier .cmd : PowerShell couperait une ligne UAT mal quotee (meme parade que BuildMod.ps1 de l'addon LifePlay-Sandbox).
$batch = [IO.Path]::ChangeExtension($log, '.cmd')
$cmd = "@call `"$uat`" BuildCookRun -project=`"$proj`" -platform=Win64 -clientconfig=$Config $buildFlag-cook -iterate -stage -pak -iostore -compressed -archive -archivedirectory=`"$ArchiveDir`" -unattended -utf8output -nop4 > `"$log`" 2>&1"
[IO.File]::WriteAllText($batch, $cmd + "`r`n@exit /b %ERRORLEVEL%`r`n", (New-Object Text.UTF8Encoding $false))

View file

@ -1,320 +0,0 @@
<#
.SYNOPSIS
Cuit un mod d'assets et l'assemble en archive installable (lot H3b, Docs/Migration/16_H3_Mods_Paquets.md).
.DESCRIPTION
Sources : ModSources\<Nom>\ (<Nom>.uplugin, Content\, Module\ ; voir Tools/ModSource.ps1).
1. controles : plugin et module (MODULE_UNIQUEID = <Nom>), version de reference Releases\<Version>\Windows, aucun
autre plugin dans Mods\ ;
0. -Anim <pack> : chaine complete d'animations du pack (Tools\anim\AnimPipeline.ps1 -Pack <pack>, lot AP7) ;
-From <etape> et -SansCuisson lui sont transmis ; rien d'autre n'est execute ;
1b. -Retarget bake|rigs : recopie les clips Quaternius sur Bridget et Michael (Tools/anim/RetargetPack.py
dans UnrealEditor.exe, Slate requis ; -RetargetConfig <json> : copie vers Saved\anim_retarget.json, methode et pack). L'editeur doit etre ferme. -RetargetOnly s'arrete avant la cuisson ;
1c. -Target sandbox|game ecrit Saved\Config\Windows\LPAnimTarget.ini. -Open lance cette cible
(sandbox : chambre d'essai ; game : partie, carte par defaut) et ne cuit pas ;
2. montage du mod dans Mods\<Nom>, cuisson DLC contre la version de reference (BuildCookRun -dlcname
-basedonreleaseversion, via cmd /c : appelee directement depuis PowerShell, la version est coupee en deux),
journal dans Saved\Logs\portage\mods ; remise en place dans ModSources\<Nom> dans tous les cas ;
3. controle du conteneur : chaque paquet est sous Mods/<Nom>/ (aucun asset du jeu embarque) ;
4. assemblage <Out>\<Nom>-for-LifePlay-<Version>\LifePlay\{Mods\<Nom>, Content\Modules\<Nom>} + notice bilingue,
archive .zip testee (7-Zip) ; un assemblage precedent du meme nom est deplace (precedent_<date>), jamais supprime ;
5. -Install <dossier du jeu> : copie le mod dans un jeu de test (dossier contenant LifePlay.exe).
.EXAMPLE
.\Tools\BuildMod.ps1 -Name lpr_hair_pocolov
.\Tools\BuildMod.ps1 -Name lpr_hair_pocolov -Retarget bake
.\Tools\BuildMod.ps1 -Name lpr_hair_pocolov -Retarget bake -RetargetOnly
.\Tools\BuildMod.ps1 -Anim freeanims
.\Tools\BuildMod.ps1 -Target sandbox -Open
.\Tools\BuildMod.ps1 -Target game -Open
.\Tools\BuildMod.ps1 -Name lpr_hair_pocolov -Install D:\Game\LifePlay_Build_5.5_H3d\Windows
#>
param(
[string] $Name = '',
[string] $Version = '',
[string] $Out = 'D:\Game\LifePlay_Publication\Mods',
[string] $Install = '',
[string] $Retarget = '',
[string] $RetargetConfig = '',
[switch] $RetargetOnly,
[string] $Target = '',
[switch] $Open,
[string] $Anim = '',
[int] $From = 0,
[switch] $SansCuisson
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path $PSScriptRoot -Parent
. (Join-Path $PSScriptRoot 'ModSource.ps1')
$UE = 'D:\Game\Launcher\Engine\UE_5.5\Engine'
$SevenZip = 'C:\Program Files\7-Zip\7z.exe'
# AP7 : chaine d'animations d'un pack.
if ($Anim) {
$pipe = @{ Pack = $Anim }
if ($PSBoundParameters.ContainsKey('From')) { $pipe.From = $From }
if ($SansCuisson) { $pipe.SansCuisson = $true }
& (Join-Path $PSScriptRoot 'anim\AnimPipeline.ps1') @pipe
exit $LASTEXITCODE
}
# AP7 : un resultat ou un echec de conversion n'est retenu que s'il porte le jeton de ce lancement
# (un ancien journal, ex. LifePlay-backup-<date>.log relu depuis l'octet 0, n'a jamais ce jeton).
function Find-RetargetLine([string] $Text, [string] $Mode, [string] $Jeton) {
$tok = [regex]::Escape($Jeton)
$success = if ($Mode -eq 'rigs') { "RETARGET rigs prets jeton $tok(?![0-9A-Za-z])" } else { "RETARGET bridget \d+ michael \d+ deplaces \d+[^\r\n]*? jeton $tok(?![0-9A-Za-z])" }
$ok = [regex]::Match($Text, $success)
$bad = [regex]::Match($Text, "RETARGET echec[^\r\n]*? jeton $tok(?![0-9A-Za-z])")
[pscustomobject]@{
Hit = if ($ok.Success) { $ok.Value.Trim() } else { $null }
Failure = if ($bad.Success) { $bad.Value.Trim() } else { $null }
}
}
function Invoke-AnimToGenesis([string] $ProjectRoot, [string] $Mode, [string] $ConfigFile = '') {
$running = @(Get-Process UnrealEditor, UnrealEditor-Cmd -ErrorAction SilentlyContinue)
if ($running) { throw "UnrealEditor est ouvert (pid $($running[0].Id)) : le fermer avant la conversion" }
$configTarget = Join-Path $ProjectRoot 'Saved\anim_retarget.json'
New-Item -ItemType Directory -Force (Split-Path $configTarget) | Out-Null
$jeton = [guid]::NewGuid().ToString('N').Substring(0, 12)
if ($ConfigFile) {
if (-not (Test-Path $ConfigFile)) { throw "-RetargetConfig : fichier introuvable $ConfigFile" }
$cfg = Get-Content -LiteralPath $ConfigFile -Raw -Encoding utf8 | ConvertFrom-Json
$cfg | Add-Member -NotePropertyName jeton -NotePropertyValue $jeton -Force
[IO.File]::WriteAllText($configTarget, (($cfg | ConvertTo-Json -Depth 20) + "`r`n"), (New-Object Text.UTF8Encoding $false))
}
else {
[IO.File]::WriteAllText($configTarget, ('{"mode": "' + $Mode.Trim().ToLower() + '", "jeton": "' + $jeton + '"}' + "`r`n"), (New-Object Text.UTF8Encoding $false))
}
$effective = Get-Content -LiteralPath $configTarget -Raw -Encoding utf8 | ConvertFrom-Json
$methode = if ($effective.methode) { [string] $effective.methode } else { 'ue4' }
$clipsDir = if ($effective.clips_dir) { [string] $effective.clips_dir } else { '/Game/Quaternius/UAL' }
$editor = Join-Path $UE 'Binaries\Win64\UnrealEditor.exe'
$project = Join-Path $ProjectRoot 'LifePlay.uproject'
$script = Join-Path $ProjectRoot 'Tools\anim\RetargetPack.py'
$logDir = Join-Path $ProjectRoot 'Saved\Logs'
# Journaux relus en entier s'ils ont change depuis le lancement : le jeton suffit a ecarter les lignes d'une ancienne
# cuisson. (AP7 : l'ancienne lecture a partir de la taille d'avant sautait le nouveau LifePlay.log, recree plus petit
# par l'editeur au demarrage, et ne voyait jamais la ligne de resultat.)
$launched = Get-Date
Write-Host "Conversion des clips de $clipsDir vers Bridget et Michael (methode $methode, $Mode)"
# Chemins entre guillemets : le dossier du projet contient une espace (AP7 : sans guillemets, l'editeur
# recevait un chemin de projet coupe et se fermait sans journal).
$proc = Start-Process -FilePath $editor -ArgumentList "`"$project`" -ExecutePythonScript=`"$script`" -nosplash -unattended" -WorkingDirectory $ProjectRoot -PassThru
$deadline = (Get-Date).AddMinutes(25)
$hit = $null
$failure = $null
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 5
$logs = @(Get-ChildItem $logDir -Filter 'LifePlay*.log' -ErrorAction SilentlyContinue)
foreach ($file in $logs) {
if ($file.LastWriteTime -lt $launched) { continue }
try {
$stream = [IO.File]::Open($file.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite)
}
catch [System.IO.IOException] { continue }
try {
$reader = New-Object IO.StreamReader($stream)
$text = $reader.ReadToEnd()
}
finally { $stream.Close() }
$found = Find-RetargetLine $text $Mode $jeton
if ($found.Hit) { $hit = $found.Hit }
if ($found.Failure) { $failure = $found.Failure }
}
if ($hit) { break }
$editorGone = -not (Get-Process UnrealEditor -ErrorAction SilentlyContinue)
if ($editorGone -and $proc.HasExited) { break }
}
if ($failure -and -not $hit) { throw "Conversion en echec : $failure" }
if (-not $hit) { throw "Conversion sans ligne RETARGET : voir Saved\Logs\LifePlay.log" }
# L'editeur se ferme seul apres la conversion (RetargetPack.py), mais l'enregistrement des assets peut prendre
# plusieurs minutes (AP7 : plus de 2 min pour 84 clips) : attendre jusqu'a 10 min que plus aucun editeur ne tourne.
$quiet = (Get-Date).AddMinutes(10)
while ((Get-Date) -lt $quiet -and @(Get-Process UnrealEditor -ErrorAction SilentlyContinue).Count -gt 0) { Start-Sleep -Seconds 2 }
$still = @(Get-Process UnrealEditor -ErrorAction SilentlyContinue)
if ($still) { throw "Conversion terminee ($hit) mais UnrealEditor est encore ouvert : le fermer avant la cuisson" }
Write-Host " $hit"
}
function Write-AnimTarget([string] $ProjectRoot, [string] $Which) {
$dir = Join-Path $ProjectRoot 'Saved\Config\Windows'
New-Item -ItemType Directory -Force $dir | Out-Null
$ini = Join-Path $dir 'LPAnimTarget.ini'
[IO.File]::WriteAllText($ini, "[LifePlay.Anim]`r`nTarget=$Which`r`n", (New-Object Text.UTF8Encoding $false))
Write-Host "Cible des animations : $Which ($ini)"
}
function Open-AnimTarget([string] $ProjectRoot, [string] $Which) {
$running = @(Get-Process UnrealEditor, UnrealEditor-Cmd -ErrorAction SilentlyContinue)
if ($running) { throw "UnrealEditor est ouvert (pid $($running[0].Id)) : le fermer avant d'ouvrir la cible" }
$editor = Join-Path $UE 'Binaries\Win64\UnrealEditor.exe'
$project = Join-Path $ProjectRoot 'LifePlay.uproject'
if ($Which -eq 'sandbox') {
# Le .bat quote l'argument -ini. Start-Process le coupe sur les deux-points.
& (Join-Path $ProjectRoot 'Tools\debug\LifePlay_interaction_sandbox.bat')
}
else {
Start-Process -FilePath $editor -ArgumentList @($project, '-game', '-windowed', '-ResX=1600', '-ResY=900') -WorkingDirectory $ProjectRoot | Out-Null
}
Write-Host "Ouvert : $Which"
}
# 1. Controles. Cuisson seulement : -Target -Open peut partir sans mod.
if ($Open -and -not $Target) { throw "-Open demande -Target sandbox ou -Target game" }
if ($Target -and $Target -notin 'sandbox', 'game') { throw "-Target vaut sandbox ou game (recu : $Target)" }
$bCook = -not $RetargetOnly -and -not $Open
if ($bCook -and -not $Name) { throw "Preciser -Name du mod" }
if (-not $bCook -and -not $Target -and -not $Retarget) { throw "Preciser -Name du mod, ou -Target sandbox|game" }
if ($bCook) {
if (-not $Version) {
$line = Select-String -Path (Join-Path $Root 'Config\DefaultGame.ini') -Pattern '^ProjectVersion=(.+)$' | Select-Object -First 1
if (-not $line) { throw 'ProjectVersion absent de Config\DefaultGame.ini : preciser -Version' }
$Version = $line.Matches[0].Groups[1].Value.Trim()
}
$src = Join-Path $Root "ModSources\$Name"
$lpmod = Join-Path $src "Module\$Name.lpmod"
if (-not (Test-Path (Join-Path $src "$Name.uplugin"))) { throw "ModSources\$Name\$Name.uplugin introuvable" }
if (-not (Test-Path $lpmod)) { throw "ModSources\$Name\Module\$Name.lpmod introuvable" }
$id = (Select-String -Path $lpmod -Pattern '^MODULE_UNIQUEID:\s*(.+)$' | Select-Object -First 1).Matches[0].Groups[1].Value.Trim()
if ($id -ne $Name) { throw "MODULE_UNIQUEID ($id) different du nom du mod ($Name)" }
$title = (Select-String -Path $lpmod -Pattern '^MODULE_NAME:\s*(.+)$' | Select-Object -First 1).Matches[0].Groups[1].Value.Trim()
# Metadonnees (H3d) : le jeu lit les trois premieres lignes par position.
$lpmodLines = @(Get-Content $lpmod -Encoding utf8)
$expected = 'MODULE_UNIQUEID:', 'MODULE_NAME:', 'MODULE_AUTHOR:'
for ($i = 0; $i -lt 3; $i++) {
if ($lpmodLines.Count -le $i -or -not $lpmodLines[$i].StartsWith($expected[$i])) { throw "$Name.lpmod : la ligne $($i + 1) doit commencer par $($expected[$i]) (le jeu lit ces lignes par position)" }
}
function Get-LpmodValue([string] $Key) {
$m = $lpmodLines | Select-String -Pattern "^$([regex]::Escape($Key)):\s*(.*)$" | Select-Object -First 1
if ($m) { $m.Matches[0].Groups[1].Value.Trim() } else { '' }
}
$modVersion = Get-LpmodValue 'MODULE_VERSION'
# Mod payant : aucune licence d'asset non commerciale ou de magasin dans les credits.
$price = Get-LpmodValue 'MODULE_PRICE'
if ($price -and $price -notmatch '^(free|gratuit|0+([.,]0+)?)$') {
$blocking = Select-String -Path (Join-Path $src 'Module\CREDITS.txt') -Pattern '\bNC\b|NonCommercial|Non-Commercial|Standard License|Personal' -ErrorAction SilentlyContinue
if ($blocking) { throw "MODULE_PRICE '$price' : licence(s) incompatible(s) avec la vente dans CREDITS.txt ($($blocking[0].Line.Trim()))" }
}
$release = Join-Path $Root "Releases\$Version\Windows"
if (-not (Test-Path (Join-Path $release 'AssetRegistry.bin'))) { throw "Version de reference absente : $release (build de publication avec -createreleaseversion=$Version)" }
$stray = Get-StrayMods $Root
if ($stray) { throw "Autres plugins dans Mods\ ($($stray -join ', ')) : les remettre dans ModSources avant de cuire" }
}
# 1b. Conversion, puis redirecteur sandbox ou game.
if ($RetargetOnly -and -not $Retarget) { $Retarget = 'bake' }
if ($Retarget -and $Retarget -notin 'bake', 'rigs') { throw "-Retarget vaut bake ou rigs (recu : $Retarget)" }
if ($Target) { Write-AnimTarget $Root $Target }
if ($Retarget) { Invoke-AnimToGenesis $Root $Retarget $RetargetConfig }
if ($Open) {
Open-AnimTarget $Root $Target
exit 0
}
if (-not $bCook) {
Write-Host "Cuisson non lancee"
exit 0
}
# 2. Cuisson DLC.
$logDir = Join-Path $Root 'Saved\Logs\portage\mods'
New-Item -ItemType Directory -Force $logDir | Out-Null
$log = Join-Path $logDir ("{0}_{1}.log" -f $Name, (Get-Date -Format 'yyyyMMdd_HHmmss'))
$mounted = Mount-ModSource $Root $Name
try {
$uplugin = Join-Path $mounted "$Name.uplugin"
# Commande ecrite dans un .cmd a cote du journal (guillemets intacts, commande exacte conservee).
$batch = [IO.Path]::ChangeExtension($log, '.cmd')
$cmd = "@call `"$UE\Build\BatchFiles\RunUAT.bat`" BuildCookRun -project=`"$Root\LifePlay.uproject`" -platform=Win64 -clientconfig=Shipping -cook -stage -pak -iostore -compressed -dlcname=`"$uplugin`" -basedonreleaseversion=$Version -nodebuginfo -unattended -utf8output -nop4 > `"$log`" 2>&1"
[IO.File]::WriteAllText($batch, $cmd + "`r`n@exit /b %ERRORLEVEL%`r`n", (New-Object Text.UTF8Encoding $false))
Write-Host "Cuisson DLC de $Name contre $Version (journal : $log)"
& cmd /c $batch
$exit = $LASTEXITCODE
$cooked = Select-String -Path $log -Pattern 'Packages Cooked: (\d+)' | Select-Object -Last 1
if ($exit -ne 0 -or -not $cooked) { throw "Cuisson en echec (code $exit) : voir $log" }
Write-Host " $($cooked.Matches[0].Groups[1].Value) paquet(s) cuit(s)"
$paks = Join-Path $mounted "Saved\StagedBuilds\Windows\LifePlay\Mods\$Name\Content\Paks\Windows"
$files = @(Get-ChildItem $paks -File | Where-Object { $_.LastWriteTime -gt (Get-Item $log).CreationTime })
if ($files.Count -lt 3) { throw "Paquets du mod absents ou anciens dans $paks" }
# 3. Contenu du conteneur : rien hors de Mods/<Nom>/.
$utoc = ($files | Where-Object Extension -eq '.utoc' | Select-Object -First 1).FullName
$entries = @(& "$UE\Binaries\Win64\UnrealPak.exe" $utoc -List 2>&1 | ForEach-Object { "$_" } | Where-Object { $_ -match '"\.\./\.\./\.\./' } | ForEach-Object { ($_ -split '"')[1] })
$outside = @($entries | Where-Object { $_ -notlike "../../../LifePlay/Mods/$Name/*" })
if (-not $entries -or $outside) { throw "Conteneur : $($entries.Count) entree(s), $($outside.Count) hors de Mods/$Name ($($outside | Select-Object -First 3))" }
Write-Host " conteneur : $($entries.Count) fichier(s), tous sous Mods/$Name/"
# 4. Assemblage.
New-Item -ItemType Directory -Force $Out | Out-Null
$base = if ($modVersion) { "$Name-$modVersion-for-LifePlay-$Version" } else { "$Name-for-LifePlay-$Version" }
$stage = Join-Path $Out $base
$zip = "$stage.zip"
$stamp = Get-Date -Format 'yyyyMMdd_HHmmss'
if (Test-Path $stage) { Move-Item $stage "$stage.precedent_$stamp" }
if (Test-Path $zip) { Move-Item $zip "$stage.precedent_$stamp.zip" }
$modDir = Join-Path $stage "LifePlay\Mods\$Name"
New-Item -ItemType Directory -Force (Join-Path $modDir 'Content\Paks\Windows') | Out-Null
Copy-Item $uplugin $modDir
$files | Copy-Item -Destination (Join-Path $modDir 'Content\Paks\Windows')
Copy-Item (Join-Path $mounted 'Module') (Join-Path $stage "LifePlay\Content\Modules\$Name") -Recurse
# Copie livree (pas les sources) : date de paquet et version du jeu visee (avertissement en jeu si elle differe).
$stagedLpmod = Join-Path $stage "LifePlay\Content\Modules\$Name\$Name.lpmod"
$stamped = @($lpmodLines | Where-Object { $_ -notmatch '^MODULE_(PACKAGED|GAME_VERSION):' }) + "MODULE_PACKAGED: $(Get-Date -Format 'yyyy-MM-dd')" + "MODULE_GAME_VERSION: $Version"
[IO.File]::WriteAllText($stagedLpmod, ($stamped -join "`r`n") + "`r`n", (New-Object Text.UTF8Encoding $false))
# Descripteur livre : version du mod et lien de l'auteur dans les champs natifs du plugin.
$stagedPlugin = Join-Path $modDir "$Name.uplugin"
$desc = Get-Content $stagedPlugin -Raw | ConvertFrom-Json
if ($modVersion) { $desc.VersionName = $modVersion }
$authorLink = Get-LpmodValue 'MODULE_AUTHOR_LINK'
if ($authorLink) { $desc | Add-Member CreatedByURL $authorLink -Force }
[IO.File]::WriteAllText($stagedPlugin, ($desc | ConvertTo-Json) + "`r`n", (New-Object Text.UTF8Encoding $false))
$readme = @(
"$title ($Name)$(if ($modVersion) { " $modVersion" }) - built for LifePlay $Version",
'',
'INSTALL: extract this archive into the game folder (the one containing LifePlay.exe), merging the LifePlay folder.',
"Then open `"Mod Manager`" on the title screen and enable the mod. To uninstall, delete LifePlay\Mods\$Name",
"and LifePlay\Content\Modules\$Name. Credits and licenses: LifePlay\Content\Modules\$Name\CREDITS.txt.",
"This mod contains cooked assets: it is made for LifePlay $Version and may need a rebuild for other versions.",
'',
'INSTALLATION : extraire cette archive dans le dossier du jeu (celui de LifePlay.exe) en fusionnant le dossier',
'LifePlay, puis activer le mod dans "Mod Manager" sur l''ecran titre. Desinstallation : supprimer',
"LifePlay\Mods\$Name et LifePlay\Content\Modules\$Name. Credits et licences : CREDITS.txt du module.",
"Ce mod contient des assets cuits pour LifePlay $Version : une autre version du jeu peut demander de le recuire.")
[IO.File]::WriteAllText((Join-Path $stage 'README.txt'), ($readme -join "`r`n") + "`r`n", (New-Object Text.UTF8Encoding $false))
}
finally {
Dismount-ModSource $Root $Name
}
Push-Location $stage
try {
& $SevenZip a -tzip $zip '.\*' | Out-Null
& $SevenZip t $zip | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Archive $zip invalide" }
}
finally { Pop-Location }
$staged = @(Get-ChildItem $stage -Recurse -File).Count
$listed = @(& $SevenZip l -slt $zip | Where-Object { $_ -match '^Attributes = [^D]*$' }).Count
if ($staged -ne $listed) { throw "Archive : $listed fichier(s) pour $staged assembles" }
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash
Write-Host ("Archive : {0} ({1:N0} octets, {2} fichiers, SHA-256 {3})" -f $zip, (Get-Item $zip).Length, $listed, $hash)
# Index de diffusion (Tools/SplitModules.ps1, mode per_module) : ligne du mod ajoutee ou remplacee.
$index = Join-Path $Out 'INDEX.txt'
if (Test-Path $index) {
$entry = '{0} ({1}) : {2}, {3:N0} bytes, SHA-256 {4}' -f $title, $Name, (Split-Path $zip -Leaf), (Get-Item $zip).Length, $hash.ToLower()
$kept = @(Get-Content $index -Encoding utf8 | Where-Object { $_ -notlike "* ($Name) : *" })
[IO.File]::WriteAllText($index, (($kept + $entry) -join "`r`n") + "`r`n", (New-Object Text.UTF8Encoding $false))
Write-Host "Index mis a jour : $index"
}
# 5. Installation dans un jeu de test.
if ($Install) {
if (-not (Test-Path (Join-Path $Install 'LifePlay.exe'))) { throw "$Install ne contient pas LifePlay.exe" }
robocopy (Join-Path $stage 'LifePlay') (Join-Path $Install 'LifePlay') /E /NFL /NDL /NJH /NJS /NP | Out-Null
if ($LASTEXITCODE -ge 8) { throw "Copie vers $Install en echec (robocopy $LASTEXITCODE)" }
Write-Host "Installe dans $Install"
}
# Code de sortie : succes (robocopy renvoie 1 a 7 quand il a copie).
exit 0

View file

@ -1,119 +0,0 @@
# Mesure la pose Idle (image 0) : mannequin, Bridget, Michael.
# But : voir si la main passe derriere le buste, et de quel angle le bras est roule.
import unreal
PAIRS = (
("source", "/Game/Quaternius/UAL/Idle_Loop.Idle_Loop", "/Game/Quaternius/UAL/UAL1_Standard.UAL1_Standard",
("pelvis", "spine_03", "clavicle_l", "upperarm_l", "lowerarm_l", "hand_l",
"clavicle_r", "upperarm_r", "lowerarm_r", "hand_r")),
("bridget", "/Game/Quaternius/UAL/B_Idle_Loop.B_Idle_Loop", "/Game/Heavy/Daz/Bridget/bridget_gp.bridget_gp",
("pelvis", "chestUpper", "lCollar", "lShldrBend", "lForearmBend", "lHand",
"rCollar", "rShldrBend", "rForearmBend", "rHand")),
("michael", "/Game/Quaternius/UAL/M_Idle_Loop.M_Idle_Loop", "/Game/Heavy/Daz/Michael/michael.michael",
("pelvis", "chestUpper", "lCollar", "lShldrBend", "lForearmBend", "lHand",
"rCollar", "rShldrBend", "rForearmBend", "rHand")),
)
FINGERS = (
"lThumb1", "lIndex1", "lMid1", "lRing1", "lPinky1",
"thumb_01_l", "index_01_l", "middle_01_l", "pinky_01_l",
"hand_l", "lHand",
)
def dot(a, b):
return a.x * b.x + a.y * b.y + a.z * b.z
def sub(a, b):
return unreal.MathLibrary.subtract_vector_vector(a, b)
def norm(v):
return unreal.MathLibrary.normal(v)
def cross(a, b):
return unreal.MathLibrary.cross_vector_vector(a, b)
def fmt(v):
return "(%.1f %.1f %.1f)" % (v.x, v.y, v.z)
def bone_names(mesh):
pose = mesh.skeleton.get_reference_pose()
names = unreal.AnimPoseExtensions.get_bone_names(pose)
return [str(n) for n in names]
def pose_at_zero(sequence):
for call in (
lambda: unreal.AnimPoseExtensions.get_anim_pose_at_time(sequence, 0.0),
lambda: unreal.AnimPoseExtensions.get_anim_pose_at_frame(sequence, 0),
):
try:
return call()
except Exception as exc:
unreal.log_warning("POSE api %s" % exc)
raise RuntimeError("pas de lecture de pose")
def bone_tm(pose, name):
return unreal.AnimPoseExtensions.get_bone_pose(pose, name, unreal.AnimPoseSpaces.WORLD)
def main():
unreal.log_warning("POSE debut")
for label, anim_path, mesh_path, bones in PAIRS:
anim = unreal.load_asset(anim_path)
mesh = unreal.load_asset(mesh_path)
if not anim or not mesh:
unreal.log_error("POSE absent %s" % label)
continue
names = bone_names(mesh)
pose = pose_at_zero(anim)
got = {}
for bone in bones:
if bone not in names:
unreal.log_warning("POSE %s os absent %s" % (label, bone))
continue
got[bone] = bone_tm(pose, bone).translation
pelvis = got.get("pelvis")
chest_name = "spine_03" if "spine_03" in got else "chestUpper"
chest = got.get(chest_name)
if not pelvis or not chest:
unreal.log_error("POSE %s sans buste" % label)
continue
up = norm(sub(chest, pelvis))
# Avant approximatif : produit du haut du buste et de l'axe gauche-droite des epaules.
left = got.get("clavicle_l") or got.get("upperarm_l") or got.get("lCollar") or got.get("lShldrBend")
right = got.get("clavicle_r") or got.get("upperarm_r") or got.get("rCollar") or got.get("rShldrBend")
if not left or not right:
unreal.log_error("POSE %s sans epaules" % label)
continue
side = norm(sub(left, right))
forward = norm(cross(side, up))
unreal.log_warning("POSE %s avant %s" % (label, fmt(forward)))
for side_name, hand_name, elbow_name in (
("gauche", "hand_l" if "hand_l" in got else "lHand", "lowerarm_l" if "lowerarm_l" in got else "lForearmBend"),
("droite", "hand_r" if "hand_r" in got else "rHand", "lowerarm_r" if "lowerarm_r" in got else "rForearmBend"),
):
hand = got.get(hand_name)
elbow = got.get(elbow_name)
if not hand:
continue
rel = sub(hand, chest)
ahead = dot(rel, forward)
unreal.log_warning(
"POSE %s main %s %s devant %.1f pos %s" % (label, side_name, hand_name, ahead, fmt(hand)))
if elbow:
unreal.log_warning("POSE %s coude %s %s" % (label, side_name, fmt(elbow)))
present = [n for n in FINGERS if n in names]
unreal.log_warning("POSE %s doigts %s" % (label, " ".join(present)))
unreal.log_warning("POSE fin")
unreal.SystemLibrary.quit_editor()
main()

View file

@ -1,34 +0,0 @@
<#
.SYNOPSIS
Ouvre l'editeur avec un mod d'assets monte (lot H3c, Docs/Modding/FullGuides/Creating_Asset_Mods.md).
.DESCRIPTION
Monte ModSources\<Nom> dans Mods\<Nom> (Tools/ModSource.ps1), lance l'editeur et attend sa fermeture, puis remet le
mod dans ModSources\<Nom> (meme si l'editeur plante). Les assets du mod apparaissent dans le navigateur de contenu
sous "<Nom> Content" (chemin /<Nom>/) ; un editeur deja ouvert ne voit pas le mod : le fermer d'abord.
.EXAMPLE
.\Tools\EditMod.ps1 -Name lpr_hair_pocolov
#>
param(
[Parameter(Mandatory)] [string] $Name
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path $PSScriptRoot -Parent
. (Join-Path $PSScriptRoot 'ModSource.ps1')
$Editor = 'D:\Game\Launcher\Engine\UE_5.5\Engine\Binaries\Win64\UnrealEditor.exe'
if (Get-Process UnrealEditor -ErrorAction SilentlyContinue) {
throw "Un editeur est deja ouvert : le fermer (il ne verrait pas le mod), puis relancer"
}
$stray = Get-StrayMods $Root
if ($stray) { throw "Autres plugins dans Mods\ ($($stray -join ', ')) : les remettre dans ModSources d'abord" }
Mount-ModSource $Root $Name | Out-Null
try {
Write-Host "Editeur ouvert avec /$Name/ ; le mod sera remis dans ModSources a sa fermeture."
Start-Process -FilePath $Editor -ArgumentList "`"$Root\LifePlay.uproject`"" -Wait
}
finally {
Dismount-ModSource $Root $Name
}

View file

@ -1,97 +0,0 @@
# Lecture des associations materiau -> textures d'un FBX binaire, sans SDK (Tools/HairPipeline.ps1).
#
# Un FBX binaire est un arbre de noeuds ; Objects contient les Material, Texture (RelativeFilename) et Video, et
# Connections les liens "OP" (texture -> propriete d'un materiau : DiffuseColor, TransparentColor, OpacityMap...) ou
# "OO" (video -> texture). Ce script les relie et ecrit, pour chaque materiau, le fichier de chaque canal.
#
# Usage : python Tools/FbxMaterialMap.py <fichier.fbx> [<sortie.json>]
# Sortie : {"<materiau>": {"DiffuseColor": "Hair_01_Gray.png", "TransparentColor": "Hair_01A1.jpg", ...}, ...}
import json
import re
import struct
import sys
import zlib
def read_nodes(data, offset, end, wide):
nodes = []
while offset < end:
if wide:
end_offset, num_props, _ = struct.unpack_from("<QQQ", data, offset)
offset += 24
else:
end_offset, num_props, _ = struct.unpack_from("<III", data, offset)
offset += 12
name_len = data[offset]
offset += 1
if end_offset == 0:
break
name = data[offset:offset + name_len].decode("ascii", "replace")
offset += name_len
props = []
for _ in range(num_props):
code = chr(data[offset])
offset += 1
if code in "YCIFDL":
fmt = {"Y": "<h", "C": "<?", "I": "<i", "F": "<f", "D": "<d", "L": "<q"}[code]
props.append(struct.unpack_from(fmt, data, offset)[0])
offset += struct.calcsize(fmt)
elif code in "SR":
length = struct.unpack_from("<I", data, offset)[0]
offset += 4
raw = data[offset:offset + length]
props.append(raw.decode("utf-8", "replace") if code == "S" else raw)
offset += length
elif code in "fdlib":
count, encoding, length = struct.unpack_from("<III", data, offset)
offset += 12 + length
props.append(None) # tableaux inutiles ici
else:
raise ValueError("type de propriete FBX inconnu : " + code)
children = read_nodes(data, offset, end_offset, wide) if offset < end_offset else []
nodes.append((name, props, children))
offset = end_offset
return nodes
def object_name(prop):
# "Nom\x00\x01Classe" -> "Nom"
return prop.split("\x00")[0] if isinstance(prop, str) else str(prop)
def material_map(path):
data = open(path, "rb").read()
if not data.startswith(b"Kaydara FBX Binary"):
raise ValueError("FBX ASCII non gere : " + path)
version = struct.unpack_from("<I", data, 23)[0]
nodes = read_nodes(data, 27, len(data), version >= 7500)
objects = next((c for n, p, c in nodes if n == "Objects"), [])
connections = next((c for n, p, c in nodes if n == "Connections"), [])
materials, textures, videos = {}, {}, {}
for name, props, children in objects:
if not props:
continue
oid = props[0]
if name == "Material":
materials[oid] = object_name(props[1])
elif name in ("Texture", "Video"):
filename = next((c[1][0] for c in children if c[0] in ("RelativeFilename", "FileName") and c[1]), "")
target = textures if name == "Texture" else videos
target[oid] = re.split(r"[\\/]", filename)[-1] if filename else object_name(props[1])
result = {}
for name, props, _ in connections:
if name != "C" or len(props) < 3:
continue
if props[0] == "OP" and props[1] in textures and props[2] in materials:
channel = props[3] if len(props) > 3 else "?"
result.setdefault(materials[props[2]], {})[channel] = textures[props[1]]
return result
if __name__ == "__main__":
mapping = material_map(sys.argv[1])
text = json.dumps(mapping, indent=1, ensure_ascii=False)
if len(sys.argv) > 2:
open(sys.argv[2], "w", encoding="utf-8").write(text)
print(text)

View file

@ -1,54 +0,0 @@
# Protocole de conversion d'une coiffure (Docs/Migration/13_Protocole_Coiffure.md), etape 2 : import du FBX.
#
# Importe les maillages d'un FBX en maillages statiques separes (sans materiaux ni textures) dans un dossier de travail,
# puis affiche pour chacun : nom, boite englobante, triangles, emplacements de materiaux. Ces informations servent a
# choisir le maillage de la coiffure (-Static) et la tete de reference (-FitHead) de LPHairFromStaticMesh.
#
# Usage :
# UnrealEditor-Cmd.exe <projet> -run=pythonscript "-script=<projet>/Tools/HairImportFbx.py fbx=<fichier.fbx> dest=/Game/Heavy/New5/Hairs/_Work" -unattended -nopause -nosplash
# Sortie : lignes "HAIRIMPORT" du journal.
import sys
import unreal
args = dict(a.split("=", 1) for a in sys.argv[1:] if "=" in a)
fbx = args.get("fbx")
dest = args.get("dest", "/Game/Heavy/New5/Hairs/_Work")
if not fbx:
unreal.log_error("HAIRIMPORT usage : fbx=<fichier.fbx> [dest=/Game/...]")
else:
task = unreal.AssetImportTask()
task.filename = fbx.replace("\\", "/")
task.destination_path = dest
task.automated = True
task.replace_existing = True
task.save = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = False
ui.import_materials = False
ui.import_textures = False
ui.mesh_type_to_import = unreal.FBXImportType.FBXIT_STATIC_MESH
ui.static_mesh_import_data.combine_meshes = False
ui.static_mesh_import_data.auto_generate_collision = False
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
count = 0
meshes = []
for path in task.imported_object_paths:
asset = unreal.load_asset(path)
if isinstance(asset, unreal.StaticMesh):
box = asset.get_bounding_box()
size = box.max - box.min
slots = [str(m.material_slot_name) for m in asset.static_materials]
unreal.log_warning("HAIRIMPORT %s : %d triangles, taille X=%.1f Y=%.1f Z=%.1f cm, min Z=%.1f max Z=%.1f, emplacements %s"
% (path, asset.get_num_triangles(0), size.x, size.y, size.z, box.min.z, box.max.z, slots))
meshes.append({"path": path.split(".")[0], "name": asset.get_name(), "triangles": asset.get_num_triangles(0),
"min": [box.min.x, box.min.y, box.min.z], "max": [box.max.x, box.max.y, box.max.z], "slots": slots})
count += 1
unreal.log_warning("HAIRIMPORT %d maillage(s) statique(s) importe(s) dans %s" % (count, dest))
# Rapport lisible par Tools/HairPipeline.ps1 (option report=<fichier.json>).
if args.get("report"):
import json
with open(args["report"], "w", encoding="utf-8") as f:
json.dump({"fbx": fbx, "dest": dest, "meshes": meshes}, f, indent=1)

View file

@ -1,104 +0,0 @@
# Protocole de conversion d'une coiffure (Docs/Migration/13_Protocole_Coiffure.md), etape 4 : textures et materiaux.
#
# Importe les textures de la coiffure, cree deux materiaux enfants de /Game/Heavy/Daz/Bridget/Hairs/DazHair (le
# materiau des coiffures Daz : parametres HairColor et HairThickness pilotes par le jeu) et les affecte aux
# emplacements du SkeletalMesh produit par LPHairFromStaticMesh :
# <Nom>_Cap : emplacements listes dans cap= (calotte) ; opacite cap_opacity= ou "white" (calotte opaque)
# <Nom>_Strands : tous les autres emplacements (meches) ; opacite strands_opacity=
# Controle final : dependances du SkeletalMesh (squelette et ces deux materiaux seulement).
#
# Usage :
# UnrealEditor-Cmd.exe <projet> -run=pythonscript "-script=<projet>/Tools/HairMaterials.py mesh=/Game/Heavy/New5/Hairs/Nom
# diffuse=<couleur.png> strands_opacity=<masque.jpg> cap_opacity=white cap=Hair_Base [thicken=1]" -unattended -nopause -nosplash
# Les fichiers de textures sont des chemins sur disque ; les textures sont creees a cote du SkeletalMesh.
# Sortie : lignes "HAIRMAT" du journal.
import os
import sys
import unreal
eal = unreal.EditorAssetLibrary
mel = unreal.MaterialEditingLibrary
tools = unreal.AssetToolsHelpers.get_asset_tools()
args = dict(a.split("=", 1) for a in sys.argv[1:] if "=" in a)
def import_texture(file, folder, name):
# Texture deja importee depuis le meme fichier : reutilisee. Une autre source dans un plugin (mod, H3) : refus,
# HairPipeline -Rebuild deplace d'abord les textures d'un mod vers la sauvegarde.
path = folder + "/" + name
if eal.does_asset_exist(path):
existing = unreal.load_asset(path)
data = existing.get_editor_property("asset_import_data")
source = data.get_first_filename().replace("\\", "/") if data else ""
if source.lower() == file.replace("\\", "/").lower():
unreal.log_warning("HAIRMAT texture %s reutilisee (meme source)" % name)
return existing
if not folder.startswith("/Game/"):
raise RuntimeError("HAIRMAT texture %s existe deja avec une autre source (%s) : relancer HairPipeline avec -Rebuild" % (path, source))
task = unreal.AssetImportTask()
task.filename = file.replace("\\", "/")
task.destination_path = folder
task.destination_name = name
task.automated = True
task.replace_existing = True
task.save = True
tools.import_asset_tasks([task])
return unreal.load_asset(folder + "/" + name)
def make_mic(folder, name, parent, diffuse, opacity, thicken):
path = folder + "/" + name
mic = unreal.load_asset(path) if eal.does_asset_exist(path) else tools.create_asset(
name, folder, unreal.MaterialInstanceConstant, unreal.MaterialInstanceConstantFactoryNew())
mel.set_material_instance_parent(mic, parent)
mel.set_material_instance_texture_parameter_value(mic, "MapDiffuse", diffuse)
mel.set_material_instance_texture_parameter_value(mic, "MapOpacity", opacity)
mel.set_material_instance_scalar_parameter_value(mic, "Thicken", thicken)
mel.update_material_instance(mic)
eal.save_loaded_asset(mic, only_if_is_dirty=False)
return mic
mesh_path = args.get("mesh")
if not mesh_path or not args.get("diffuse") or not args.get("strands_opacity"):
unreal.log_error("HAIRMAT usage : mesh=/Game/... diffuse=<fichier> strands_opacity=<fichier> [cap_opacity=<fichier>|white] [cap=Slot1,Slot2] [thicken=1]")
else:
folder, name = mesh_path.rsplit("/", 1)
# Dans un plugin (mod, H3), le registre de la commandlet ne connait pas encore les assets du dossier : sans cette
# analyse, does_asset_exist repond faux, la creation d'un asset existant ouvre une boite de dialogue et plante.
unreal.AssetRegistryHelpers.get_asset_registry().scan_paths_synchronous([folder], True)
thicken = float(args.get("thicken", "1"))
cap_slots = [s for s in args.get("cap", "").split(",") if s]
parent = unreal.load_asset("/Game/Heavy/Daz/Bridget/Hairs/DazHair")
diffuse = import_texture(args["diffuse"], folder, name + "_Diffuse")
strands_opacity = import_texture(args["strands_opacity"], folder, name + "_StrandsOpacity")
strands = make_mic(folder, name + "_Strands", parent, diffuse, strands_opacity, thicken)
# Calotte : seulement si un emplacement lui est attribue (Pocolov 11 : emplacement unique, pas de calotte).
cap = None
if cap_slots:
cap_arg = args.get("cap_opacity", "white")
cap_opacity = unreal.load_asset("/Engine/EngineResources/WhiteSquareTexture") if cap_arg == "white" else import_texture(cap_arg, folder, name + "_CapOpacity")
cap = make_mic(folder, name + "_Cap", parent, diffuse, cap_opacity, 1.0)
# Affectation : modifier les elements puis reaffecter le tableau entier et sauvegarder sans condition
# (une affectation Python ne marque pas l'asset modifie).
mesh = unreal.load_asset(mesh_path)
materials = []
used = set()
for slot in mesh.get_editor_property("materials"):
slot_name = str(slot.get_editor_property("material_slot_name"))
slot.set_editor_property("material_interface", cap if slot_name in cap_slots else strands)
materials.append(slot)
used.add(folder + "/" + (cap if slot_name in cap_slots else strands).get_name())
unreal.log_warning("HAIRMAT emplacement %s -> %s" % (slot_name, (cap if slot_name in cap_slots else strands).get_name()))
mesh.set_editor_property("materials", materials)
eal.save_loaded_asset(mesh, only_if_is_dirty=False)
registry = unreal.AssetRegistryHelpers.get_asset_registry()
# Chemin de paquet (et non de fichier) : vaut aussi pour un plugin de mod (/<Mod>/..., H3).
registry.scan_paths_synchronous([folder], force_rescan=True)
options = unreal.AssetRegistryDependencyOptions(include_soft_package_references=True, include_hard_package_references=True)
deps = [str(d) for d in registry.get_dependencies(mesh_path, options) if not str(d).startswith("/Script/")]
expected = {mesh_path + "_Skeleton"} | used
unreal.log_warning("HAIRMAT dependances de %s : %s -> %s" % (name, deps, "OK" if set(deps) == expected else "A VERIFIER"))

View file

@ -1,284 +0,0 @@
<#
.SYNOPSIS
Chaine industrialisee de conversion des coiffures (Docs/Migration/13_Protocole_Coiffure.md, section 7).
.DESCRIPTION
Pilotee par un fichier JSON (un lot de coiffures). Deux etapes :
-Step Inspect : pour chaque coiffure
1. extrait l'archive (zips imbriques compris) dans <backup>\<nom>\src ; copie le FBX et les textures sous des
noms sans espaces ;
2. importe le FBX en maillages statiques (Tools/HairImportFbx.py) dans <folder>/_Work_<nom> ;
3. propose : maillage de la coiffure, tete de reference (nom contenant "head"), emplacements de calotte (nom
contenant base, cap ou scalp), texture de couleur et masque des meches (analyse des images : niveaux de gris,
proportion de pixels noirs/blancs, orientation) ;
4. ecrit la proposition dans le fichier JSON (champs "auto" remplaces) et un rapport.
-> relire le rapport, corriger le JSON si besoin.
-Step Apply : pour chaque coiffure inspectee
1. LPHairFromStaticMesh (essai a blanc, controle des echelles, puis conversion) ;
2. Tools/HairMaterials.py (textures, materiaux DazHair, controle des dependances) ;
3. dossier _Work_<nom> deplace vers <backup>\<nom>\work (jamais supprime) ;
4. Modules/<module>/Hairstyles/<nom>.lphair (attribution comprise) ; credits dans
Licenses/Free_3D_Models_Credits.txt (une ligne par coiffure, sans doublon) ;
puis Tools/BuildCollections.py et un bilan. Le build et le controle en jeu restent manuels (protocole, etapes 6 et 7).
Une coiffure dont le SkeletalMesh existe deja est sautee (rien n'est ecrase), sauf avec -Rebuild : la coiffure et son
squelette precedents sont alors deplaces dans <backup>\<nom>\precedente_<date> avant reconstruction.
Options de LPHairFromStaticMesh par coiffure dans le JSON : margin, scale, offset_z, offset_y, skin (transfer|rigid),
gap (cm).
Mod d'assets (H3b) : avec "mod": "<Nom>" et "folder": "/<Nom>/Hairs", les sources ModSources\<Nom> sont montees dans
Mods\<Nom> pendant le travail (Tools/ModSource.ps1) puis remises en place ; les .lphair et les credits vont dans
ModSources\<Nom>\Module ; -Rebuild deplace aussi les textures du mod vers la sauvegarde (pas de remplacement de
texture dans un plugin). Le mod se cuit ensuite avec Tools/BuildMod.ps1.
.EXAMPLE
.\Tools\HairPipeline.ps1 -Config Tools\hairs\HAIRS1.json -Step Inspect
.\Tools\HairPipeline.ps1 -Config Tools\hairs\HAIRS1.json -Step Apply
#>
param(
[Parameter(Mandatory)] [string] $Config,
[Parameter(Mandatory)] [ValidateSet('Inspect', 'Apply')] [string] $Step,
[string] $Only = '',
[switch] $Rebuild
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path $PSScriptRoot -Parent
$Proj = Join-Path $Root 'LifePlay.uproject'
$UE = 'D:\Game\Launcher\Engine\UE_5.5\Engine\Binaries\Win64\UnrealEditor-Cmd.exe'
$Tools = ($PSScriptRoot -replace '\\', '/')
Add-Type -AssemblyName System.Drawing
. (Join-Path $PSScriptRoot 'ModSource.ps1')
$cfg = Get-Content $Config -Raw | ConvertFrom-Json
$backup = $cfg.backup
$folder = $cfg.folder
$mod = $cfg.mod
New-Item -ItemType Directory -Force $backup | Out-Null
function Invoke-UE([string[]] $Arguments, [string] $Filter) {
$out = & $UE $Proj @Arguments -unattended -nopause -nosplash 2>&1 | ForEach-Object { "$_" }
$out | Where-Object { $_ -match $Filter -and $_ -notmatch 'LogInit: Display' }
}
# Chemin disque d'un chemin de paquet : /Game/X -> Content/X ; /<Plugin>/X -> Mods/<Plugin>/Content/X (mod, H3)
# ou Plugins/<Plugin>/Content/X.
function Get-DiskPath([string] $PackagePath) {
$parts = $PackagePath.TrimStart('/').Split('/', 2)
if ($parts[0] -eq 'Game') { return Join-Path $Root ('Content\' + ($parts[1] -replace '/', '\')) }
foreach ($base in 'Mods', 'Plugins') {
$pluginDir = Join-Path $Root "$base\$($parts[0])"
if (Test-Path $pluginDir) { return Join-Path $pluginDir ('Content\' + ($parts[1] -replace '/', '\')) }
}
throw "Dossier de contenu introuvable pour $PackagePath"
}
$SevenZip = 'C:\Program Files\7-Zip\7z.exe'
function Expand-One([string] $Archive, [string] $Dest) {
# 7-Zip lit zip, rar et 7z (Pocolov 03 : source/Hair3.rar) ; Expand-Archive en secours pour les zip.
if (Test-Path $SevenZip) { & $SevenZip x -y "-o$Dest" $Archive | Out-Null }
else { Expand-Archive -LiteralPath $Archive -DestinationPath $Dest -Force }
}
function Expand-All([string] $Archive, [string] $Dest) {
Expand-One $Archive $Dest
# Archives imbriquees (Pocolov : source/<nom>.zip ou .rar)
for ($pass = 0; $pass -lt 3; $pass++) {
$inner = Get-ChildItem $Dest -Recurse -Include *.zip, *.rar, *.7z | Where-Object { -not (Test-Path ($_.FullName + '.done')) }
if (-not $inner) { break }
foreach ($z in $inner) {
Expand-One $z.FullName (Join-Path $z.DirectoryName ($z.BaseName -replace '\s', '_'))
New-Item -ItemType File ($z.FullName + '.done') | Out-Null
}
}
}
# Statistiques d'une image : saturation moyenne, part de pixels tres sombres / tres clairs, part sombre du bas.
function Get-ImageStats([string] $Path) {
try { $bmp = [System.Drawing.Bitmap]::FromFile($Path) } catch { return $null }
try {
$step = [Math]::Max(1, [int]([Math]::Sqrt($bmp.Width * $bmp.Height / 20000)))
$n = 0; $sat = 0.0; $dark = 0; $light = 0; $bottomDark = 0; $bottomN = 0; $sideDark = 0; $sideN = 0
for ($y = 0; $y -lt $bmp.Height; $y += $step) {
for ($x = 0; $x -lt $bmp.Width; $x += $step) {
$c = $bmp.GetPixel($x, $y)
$max = [Math]::Max($c.R, [Math]::Max($c.G, $c.B)); $min = [Math]::Min($c.R, [Math]::Min($c.G, $c.B))
$sat += ($max - $min) / 255.0
$l = ($c.R + $c.G + $c.B) / 765.0
if ($l -lt 0.15) { $dark++ } elseif ($l -gt 0.85) { $light++ }
if ($y -gt $bmp.Height * 0.8) { $bottomN++; if ($l -lt 0.3) { $bottomDark++ } }
if ($x -lt $bmp.Width * 0.05 -or $x -ge $bmp.Width * 0.95) { $sideN++; if ($l -lt 0.3) { $sideDark++ } }
$n++
}
}
[pscustomobject]@{
File = $Path; Width = $bmp.Width; Height = $bmp.Height
Saturation = [Math]::Round($sat / $n, 3)
Binary = [Math]::Round(($dark + $light) / $n, 3)
Dark = [Math]::Round($dark / $n, 3)
BottomDark = if ($bottomN) { [Math]::Round($bottomDark / $bottomN, 3) } else { 0 }
SideDark = if ($sideN) { [Math]::Round($sideDark / $sideN, 3) } else { 0 }
}
} finally { $bmp.Dispose() }
}
function Select-Textures($all, [string[]] $StrandSlots) {
# Archive avec une tete complete (Pocolov 03 : peau, yeux, sourcils) : ne garder que les textures nommees "hair"
# s'il y en a.
$stats = @($all | Where-Object { (Split-Path $_.File -Leaf) -match 'hair' })
if (-not $stats) { $stats = $all }
# Masque : niveaux de gris, majoritairement noir/blanc. Masque des meches : vertical (hauteur > largeur ; un masque
# carre est en general la lisiere de la calotte, Pocolov 16) ; a
# egalite, celui dont le nom contient un emplacement des meches (Hair_01 -> Hair_01A1), puis l'ordre alphabetique
# (A1 avant A2). Couleur : le reste ; de preference grise (teintee par HairColor), sinon la plus grande.
$masks = $stats | Where-Object { $_.Saturation -lt 0.05 -and $_.Binary -gt 0.55 }
$matchesSlot = { param($f) foreach ($s in $StrandSlots) { if ($s -and (Split-Path $f -Leaf) -match [regex]::Escape($s)) { return $true } }; $false }
# Puis bords lateraux sombres (carte de meches a bords transparents : Hair_01A1 plutot que Hair_01A, Pocolov 03).
$strands = $masks | Where-Object { $_.Height -gt $_.Width } | Sort-Object @{ e = { & $matchesSlot $_.File }; Descending = $true }, @{ e = { $_.SideDark -gt 0.5 }; Descending = $true }, @{ e = { Split-Path $_.File -Leaf } } | Select-Object -First 1
$colors = $stats | Where-Object { $_ -notin $masks }
$diffuse = $colors | Sort-Object @{ e = { $_.Saturation -lt 0.05 }; Descending = $true }, @{ e = { $_.Height * $_.Width }; Descending = $true } | Select-Object -First 1
[pscustomobject]@{ Diffuse = $diffuse; Strands = $strands }
}
$report = @()
if ($mod) {
if ($folder -notlike "/$mod/*") { throw "folder ($folder) doit etre dans /$mod/" }
Mount-ModSource $Root $mod | Out-Null
}
try {
foreach ($h in $cfg.hairs) {
if ($Only -and $h.name -ne $Only) { continue }
$name = $h.name
$dir = Join-Path $backup $name
$meshPath = "$folder/$name"
$work = "$folder/_Work_$name"
if ($Step -eq 'Inspect') {
Write-Host "== $name : inspection"
if (-not (Test-Path $h.archive)) { $report += "$name : archive absente ($($h.archive))"; continue }
Remove-Item (Join-Path $dir 'src') -Recurse -Force -ErrorAction SilentlyContinue
Expand-All $h.archive (Join-Path $dir 'src')
$norm = Join-Path $dir 'norm'
New-Item -ItemType Directory -Force $norm | Out-Null
$fbx = Get-ChildItem (Join-Path $dir 'src') -Recurse -Filter *.fbx | Sort-Object Length -Descending | Select-Object -First 1
if (-not $fbx) { $report += "$name : aucun FBX dans l'archive"; continue }
Copy-Item $fbx.FullName (Join-Path $norm 'hair.fbx') -Force
$i = 0; $stats = @()
foreach ($t in Get-ChildItem (Join-Path $dir 'src') -Recurse -Include *.png, *.jpg, *.jpeg, *.tga, *.bmp) {
$i++
$copy = Join-Path $norm ("tex{0:D2}_{1}{2}" -f $i, ($t.BaseName -replace '[^A-Za-z0-9_-]', '_'), $t.Extension.ToLower())
Copy-Item $t.FullName $copy -Force
$s = Get-ImageStats $copy
if ($s) { $stats += $s }
}
$json = Join-Path $dir 'import.json'
$fbxArg = (Join-Path $norm 'hair.fbx') -replace '\\', '/'
Invoke-UE @('-run=pythonscript', "-script=$Tools/HairImportFbx.py fbx=$fbxArg dest=$work report=$($json -replace '\\','/')") 'HAIRIMPORT' | Write-Host
if (-not (Test-Path $json)) { $report += "$name : import du FBX echoue"; continue }
$imp = Get-Content $json -Raw | ConvertFrom-Json
$head = $imp.meshes | Where-Object { $_.name -match 'head' } | Sort-Object triangles -Descending | Select-Object -First 1
$hairMeshes = @($imp.meshes | Where-Object { $_ -ne $head })
$hair = $hairMeshes | Sort-Object triangles -Descending | Select-Object -First 1
# Champs detectes : ceux valant "auto" a la premiere inspection (memorises dans auto_fields, recalcules a chaque
# inspection) ; une valeur saisie a la main n'est jamais remplacee.
if (-not $h.auto_fields) { $h | Add-Member auto_fields @('mesh', 'head', 'cap_slots', 'diffuse', 'strands_opacity' | Where-Object { $h.$_ -eq 'auto' }) -Force }
$auto = { param($f) $f -in $h.auto_fields }
if (& $auto 'mesh') { $h.mesh = $hair.path }
if (& $auto 'head') { $h.head = if ($head) { $head.path } else { $hair.path } }
# Associations materiau -> textures declarees dans le FBX (Tools/FbxMaterialMap.py), pour le rapport et le cas
# d'un emplacement unique.
$fbxMap = $null
try { $fbxMap = (& python (Join-Path $PSScriptRoot 'FbxMaterialMap.py') (Join-Path $norm 'hair.fbx')) -join "`n" | ConvertFrom-Json } catch { }
$findCopy = { param($fileName) if ($fileName) { $b = [IO.Path]::GetFileNameWithoutExtension($fileName) -replace '[^A-Za-z0-9_-]', '_'; ($stats | Where-Object { (Split-Path $_.File -Leaf) -match ('^tex\d+_' + [regex]::Escape($b) + '\.') } | Select-Object -First 1).File } }
# Calotte : emplacement nomme base/cap/scalp, seulement s'il reste au moins un autre emplacement pour les meches.
if (& $auto 'cap_slots') { $h.cap_slots = if ($hair.slots.Count -gt 1) { (@($hair.slots | Where-Object { $_ -match 'base|cap|scalp' }) -join ',') } else { '' } }
$strandSlots = @($hair.slots | Where-Object { $_ -notin ($h.cap_slots -split ',') })
$sel = Select-Textures $stats $strandSlots
if (& $auto 'diffuse') { $h.diffuse = if ($sel.Diffuse) { $sel.Diffuse.File -replace '\\', '/' } else { '' } }
if (& $auto 'strands_opacity') {
# Emplacement unique (Pocolov 11) : masque declare par le FBX pour cet emplacement ; sinon masque vertical.
$declared = if ($strandSlots.Count -eq 1 -and $hair.slots.Count -eq 1 -and $fbxMap) { & $findCopy $fbxMap.($strandSlots[0]).TransparentColor }
$h.strands_opacity = if ($declared) { $declared -replace '\\', '/' } elseif ($sel.Strands) { $sel.Strands.File -replace '\\', '/' } else { '' }
}
$notes = @()
if ($hairMeshes.Count -gt 1) { $notes += "$($hairMeshes.Count) maillages hors tete : seul '$($hair.name)' est converti" }
if (-not $head) { $notes += 'pas de tete de reference : ajustement sur la coiffure elle-meme (margin 1.0)'; if (-not $h.margin) { $h | Add-Member margin '1.0' -Force } }
if (-not $h.diffuse -or -not $h.strands_opacity) { $notes += 'textures non identifiees : renseigner diffuse / strands_opacity' }
$h | Add-Member inspected $true -Force
$report += "$name : coiffure $($hair.name) ($($hair.triangles) tri., emplacements $($hair.slots -join ',')), tete $(if ($head) { $head.name } else { '-' }), calotte [$($h.cap_slots)], couleur $(Split-Path $h.diffuse -Leaf), masque $(Split-Path $h.strands_opacity -Leaf)$(if ($notes) { ' ; ' + ($notes -join ' ; ') })"
if ($fbxMap) { $report += ($fbxMap.PSObject.Properties | ForEach-Object { " FBX $($_.Name) : " + (($_.Value.PSObject.Properties | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join ', ') }) }
$report += ($stats | ForEach-Object { " texture $(Split-Path $_.File -Leaf) : $($_.Width)x$($_.Height), saturation $($_.Saturation), noir/blanc $($_.Binary), bas sombre $($_.BottomDark), bords sombres $($_.SideDark)" })
}
else {
Write-Host "== $name : application"
if (-not $h.inspected) { $report += "$name : non inspectee, sautee"; continue }
$existing = (Get-DiskPath $meshPath) + '.uasset'
if (Test-Path $existing) {
if (-not $Rebuild) { $report += "$name : $meshPath existe deja, sautee (-Rebuild pour la refaire)"; continue }
# -Rebuild : coiffure et squelette precedents deplaces vers la sauvegarde (materiaux et textures mis a jour sur place).
$previous = Join-Path $dir ('precedente_' + (Get-Date -Format 'yyyyMMdd_HHmmss'))
New-Item -ItemType Directory -Force $previous | Out-Null
Move-Item $existing $previous
Move-Item ($existing -replace '\.uasset$', '_Skeleton.uasset') $previous -ErrorAction SilentlyContinue
if ($mod) {
foreach ($suffix in '_Diffuse', '_StrandsOpacity', '_CapOpacity') {
Move-Item ($existing -replace '\.uasset$', "$suffix.uasset") $previous -ErrorAction SilentlyContinue
}
}
}
$common = @("-Static=$($h.mesh)", "-FitHead=$($h.head)", "-Out=$meshPath")
if ($h.margin) { $common += "-Margin=$($h.margin)" }
foreach ($k in 'scale', 'offset_z', 'offset_y', 'skin', 'gap') { if ($h.$k) { $common += ('-' + @{ scale = 'Scale'; offset_z = 'OffsetZ'; offset_y = 'OffsetY'; skin = 'Skin'; gap = 'Gap' }[$k] + '=' + $h.$k) } }
$dry = Invoke-UE (@('-run=LPHairFromStaticMesh') + $common + '-DryRun') 'LogLPHairFromStatic: (Warning|Error)'
$dry | Write-Host
$scaleLine = $dry | Where-Object { $_ -match 'echelle X=([\d.]+) Y=([\d.]+)' } | Select-Object -First 1
if (-not $scaleLine) { $report += "$name : essai a blanc en echec"; continue }
$sx = [double]$Matches[1]; $sy = [double]$Matches[2]
if ($sx -lt 0.6 -or $sx -gt 1.8 -or $sy -lt 0.6 -or $sy -gt 1.8) { $report += "$name : echelle hors limites (X=$sx Y=$sy), sautee : verifier la tete de reference"; continue }
$conv = Invoke-UE (@('-run=LPHairFromStaticMesh') + $common) 'LogLPHairFromStatic: (Warning|Error)'
if (-not ($conv -match 'sauvegarde :.*' + [regex]::Escape($name) + '\.uasset')) { $conv | Write-Host; $report += "$name : conversion en echec"; continue }
$placed = ($conv | Where-Object { $_ -match 'bornes' }) -replace '.*bornes ', ''
$mat = Invoke-UE @('-run=pythonscript', "-script=$Tools/HairMaterials.py mesh=$meshPath diffuse=$($h.diffuse) strands_opacity=$($h.strands_opacity) cap_opacity=$($h.cap_opacity) cap=$($h.cap_slots) thicken=$($h.thicken)") 'HAIRMAT'
$mat | Write-Host
$depOk = [bool]($mat -match 'dependances.*-> OK')
# Nettoyage : dossier de travail deplace vers la sauvegarde.
$workDir = Get-DiskPath $work
if (Test-Path $workDir) { Move-Item $workDir (Join-Path $dir 'work') -Force }
# Declaration et credits.
# Mod (H3b) : module texte du mod, dans ses sources (montees dans Mods\<mod> pendant le travail).
$moduleDir = if ($mod) { Join-Path $Root "Mods\$mod\Module" } else { Join-Path $Root "Content\Modules\$($cfg.module)" }
$lphairDir = Join-Path $moduleDir 'Hairstyles'
New-Item -ItemType Directory -Force $lphairDir | Out-Null
$credit = "$($h.title) by $($h.author), $($h.license), $($h.url)"
$lines = @(
"// $($h.title) - $($h.author) - licence $($h.license)",
"// Source : $($h.url)",
'// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.',
"HAIRSTYLE: $meshPath.$name", "SEX: $($h.sex)", 'DAZ: true')
[IO.File]::WriteAllText((Join-Path $lphairDir "$($name.ToLower()).lphair"), ($lines -join "`r`n") + "`r`n", (New-Object Text.UTF8Encoding $false))
# Credits : fichier du jeu, ou Module\CREDITS.txt d'un mod.
$credits = if ($mod) { Join-Path $moduleDir 'CREDITS.txt' } else { Join-Path $Root 'Licenses\Free_3D_Models_Credits.txt' }
if (-not (Test-Path $credits)) { New-Item -ItemType File -Force $credits | Out-Null }
if (-not (Select-String -LiteralPath $credits -SimpleMatch $h.url -Quiet)) {
Add-Content -LiteralPath $credits -Value ("- $credit . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials ($meshPath*).") -Encoding utf8
}
$report += "$name : OK, echelle X=$sx Y=$sy, bornes $placed, dependances $(if ($depOk) { 'OK' } else { 'A VERIFIER' })"
}
}
if ($Step -eq 'Inspect') {
$cfg | ConvertTo-Json -Depth 6 | Set-Content $Config -Encoding utf8
}
else {
Invoke-UE @('-run=pythonscript', "-script=$Tools/BuildCollections.py") 'collection\(s\) ecrite' | Write-Host
}
}
finally {
if ($mod) { Dismount-ModSource $Root $mod }
}
$reportFile = Join-Path $backup "rapport_$Step.txt"
$report | Set-Content $reportFile -Encoding utf8
Write-Host "`n=== Bilan ($Step) : $reportFile"
$report | Write-Host

View file

@ -1,40 +0,0 @@
# Importe le mannequin et les animations CC0 de Quaternius (Universal Animation Library, sans root motion).
#
# Usage :
# UnrealEditor-Cmd.exe <projet> -run=pythonscript "-script=<projet>/Tools/ImportQuaternius.py" -unattended -nopause -nosplash
# Sortie : lignes "QUATERNIUS" du journal.
import unreal
glb = "D:/Game/Universal Animation Library[Standard]/Unreal-Godot/UAL1_Standard.glb"
dest = "/Game/Quaternius/UAL"
task = unreal.AssetImportTask()
task.filename = glb
task.destination_path = dest
task.automated = True
task.replace_existing = True
task.save = True
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
reported = list(task.imported_object_paths)
unreal.log_warning("QUATERNIUS tache %d objet(s)" % len(reported))
for path in reported:
unreal.log_warning("QUATERNIUS PATH %s" % path)
if not unreal.EditorAssetLibrary.does_directory_exist(dest):
unreal.log_error("QUATERNIUS dossier absent %s" % dest)
else:
unreal.EditorAssetLibrary.save_directory(dest, False, True)
assets = unreal.EditorAssetLibrary.list_assets(dest, True, False)
unreal.log_warning("QUATERNIUS dossier %d asset(s)" % len(assets))
for path in assets:
asset = unreal.load_asset(path)
kind = asset.get_class().get_name() if asset else "null"
extra = ""
if isinstance(asset, unreal.SkeletalMesh):
box = asset.get_bounds()
extra = " origin=(%.1f %.1f %.1f) extent=(%.1f %.1f %.1f)" % (
box.origin.x, box.origin.y, box.origin.z,
box.box_extent.x, box.box_extent.y, box.box_extent.z)
unreal.log_warning("QUATERNIUS ASSET %s %s%s" % (kind, path, extra))

View file

@ -1,51 +0,0 @@
<#
.SYNOPSIS
Montage temporaire d'un mod d'assets (lot H3b, Docs/Migration/16_H3_Mods_Paquets.md).
.DESCRIPTION
Les sources d'un mod vivent dans ModSources\<Nom>\ :
<Nom>.uplugin plugin de contenu (CanContainContent)
Content\ assets du mod (non versionnes, comme Content/Heavy)
Module\ module LifePlay en texte (<Nom>.lpmod, Hairstyles\*.lphair, CREDITS.txt)
L'editeur ne monte /<Nom>/ que si le plugin est dans <projet>\Mods\ ; mais un plugin laisse dans Mods\ serait cuit
avec le jeu de base. Mount-ModSource deplace donc ModSources\<Nom> vers Mods\<Nom> le temps d'un travail (import,
cuisson DLC) et Dismount-ModSource le remet en place (deplacements seulement, rien n'est copie ni supprime).
A charger par dot-sourcing : . (Join-Path $PSScriptRoot 'ModSource.ps1')
#>
function Get-ModSourcePaths([string] $Root, [string] $Name) {
[pscustomobject]@{
Source = Join-Path $Root "ModSources\$Name"
Mounted = Join-Path $Root "Mods\$Name"
}
}
function Mount-ModSource([string] $Root, [string] $Name) {
$p = Get-ModSourcePaths $Root $Name
if (Test-Path $p.Mounted) {
throw "Mods\$Name existe deja (travail precedent interrompu ?) : le remettre dans ModSources\$Name a la main avant de relancer"
}
if (-not (Test-Path (Join-Path $p.Source "$Name.uplugin"))) {
throw "ModSources\$Name\$Name.uplugin introuvable"
}
New-Item -ItemType Directory -Force (Split-Path $p.Mounted -Parent) | Out-Null
Move-Item $p.Source $p.Mounted
Write-Host "Mod $Name monte dans Mods\$Name"
$p.Mounted
}
function Dismount-ModSource([string] $Root, [string] $Name) {
$p = Get-ModSourcePaths $Root $Name
if (Test-Path $p.Mounted) {
Move-Item $p.Mounted $p.Source
Write-Host "Mod $Name remis dans ModSources\$Name"
}
}
# Autres plugins presents dans Mods\ (ils seraient cuits avec le jeu de base).
function Get-StrayMods([string] $Root, [string] $Except = '') {
$mods = Join-Path $Root 'Mods'
if (-not (Test-Path $mods)) { return @() }
@(Get-ChildItem $mods -Directory | Where-Object { $_.Name -ne $Except } | ForEach-Object Name)
}

View file

@ -1,78 +0,0 @@
<#
.SYNOPSIS
Cree le squelette d'un mod d'assets (lot H3c, Docs/Modding/FullGuides/Creating_Asset_Mods.md).
.DESCRIPTION
Cree ModSources\<Nom>\ :
<Nom>.uplugin plugin de contenu (assets cuits a part du jeu)
Content\ assets du mod (a remplir dans l'editeur : Tools/EditMod.ps1)
Module\<Nom>.lpmod module LifePlay (MODULE_UNIQUEID = <Nom>, active depuis le gestionnaire de mods)
Module\CREDITS.txt credits et licences des assets
et, avec -Hairs, Tools\mods\<Nom>.json (configuration de Tools/HairPipeline.ps1, liste de coiffures vide).
Refuse un nom deja pris (sources, module du jeu ou plugin).
.EXAMPLE
.\Tools\NewMod.ps1 -Name MyHairPack -Title "My Hair Pack" -Author "Me" -License "CC BY 4.0" -AuthorLink https://example.org -Hairs
#>
param(
[Parameter(Mandatory)] [ValidatePattern('^[A-Za-z][A-Za-z0-9_]{2,40}$')] [string] $Name,
[Parameter(Mandatory)] [string] $Title,
[Parameter(Mandatory)] [string] $Author,
[string] $Description = '',
[string] $Link = '',
[string] $ModVersion = '1.0',
[string] $License = '',
[string] $AuthorLink = '',
[string] $Price = 'Free',
[switch] $Hairs
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path $PSScriptRoot -Parent
$src = Join-Path $Root "ModSources\$Name"
foreach ($taken in $src, (Join-Path $Root "Mods\$Name"), (Join-Path $Root "Content\Modules\$Name"), (Join-Path $Root "Plugins\$Name")) {
if (Test-Path $taken) { throw "Nom deja pris : $taken" }
}
$utf8 = New-Object Text.UTF8Encoding $false
New-Item -ItemType Directory -Force (Join-Path $src 'Content'), (Join-Path $src 'Module') | Out-Null
$plugin = [ordered]@{
FileVersion = 3; Version = 1; VersionName = $ModVersion
FriendlyName = $Title
Description = $(if ($Description) { $Description } else { "LifePlay mod: $Title" })
Category = 'LifePlay Mods'; CreatedBy = $Author
CanContainContent = $true; IsBetaVersion = $false; Installed = $false
}
if ($AuthorLink) { $plugin.CreatedByURL = $AuthorLink }
[IO.File]::WriteAllText((Join-Path $src "$Name.uplugin"), ($plugin | ConvertTo-Json) + "`r`n", $utf8)
$lpmod = @(
"MODULE_UNIQUEID: $Name",
"MODULE_NAME: $Title",
"MODULE_AUTHOR: $Author",
"MODULE_LINK: $Link",
"MODULE_DESCRIPTION: $(if ($Description) { $Description } else { $Title })",
'MODULE_REQUIREMENTS: vin_Base',
# Metadonnees (H3d) ; MODULE_PACKAGED et MODULE_GAME_VERSION sont ecrites par BuildMod.ps1 dans la copie livree.
"MODULE_VERSION: $ModVersion",
"MODULE_CREATED: $(Get-Date -Format 'yyyy-MM-dd')",
"MODULE_LICENSE: $License",
"MODULE_AUTHOR_LINK: $AuthorLink",
"MODULE_PRICE: $Price")
[IO.File]::WriteAllText((Join-Path $src "Module\$Name.lpmod"), ($lpmod -join "`r`n") + "`r`n", $utf8)
[IO.File]::WriteAllText((Join-Path $src 'Module\CREDITS.txt'), "Credits and licenses of the assets in $Title (one line per asset: title, author, license, source URL, changes).`r`n", $utf8)
if ($Hairs) {
$cfgPath = Join-Path $Root "Tools\mods\$Name.json"
if (Test-Path $cfgPath) { throw "$cfgPath existe deja" }
$cfg = [ordered]@{
mod = $Name; module = $Name; folder = "/$Name/Hairs"
backup = "D:/Game/LifePlay_Backup_5.5/mods/$Name"
hairs = @()
}
New-Item -ItemType Directory -Force (Split-Path $cfgPath -Parent) | Out-Null
[IO.File]::WriteAllText($cfgPath, ($cfg | ConvertTo-Json -Depth 4) + "`r`n", $utf8)
Write-Host "Configuration des coiffures : $cfgPath (ajouter les entrees de 'hairs', voir Tools/mods/lpr_hair_pocolov.json)"
}
Write-Host "Mod cree : $src"
Write-Host "Suite : .\Tools\EditMod.ps1 -Name $Name (assets dans l'editeur), puis .\Tools\BuildMod.ps1 -Name $Name"

View file

@ -18,7 +18,7 @@
.PARAMETER (configuration "per_module": true, depuis la publication qui suit la V1.4)
Une archive par module optionnel, <module>-for-LifePlay-<version>.zip (LifePlay\Content\Modules\<module> + notice
bilingue tiree du .lpmod), dans <Out>\<out_dir> ; un index INDEX.txt (nom, archive, taille, SHA-256) sert au message
de diffusion (Discord). Les mods d'assets (Tools/BuildMod.ps1) se deposent dans le meme dossier.
de diffusion (Discord). Les mods d'assets (LifePlay-Sandbox/Tools/BuildMod.ps1, atelier de mods de l'addon, lot DC3) se deposent dans le meme dossier.
.EXAMPLE
.\Tools\SplitModules.ps1 -Build D:\Game\LifePlay_Build_5.5_Shipping_V1.5 -Config Tools\modules
@ -38,7 +38,7 @@ $modules = Join-Path $Build 'Windows\LifePlay\Content\Modules'
if (-not (Test-Path $modules)) { throw "Dossier des modules introuvable : $modules" }
# Un build de distribution ne contient aucun mod d'assets installe (sa partie texte serait livree sans ses paquets) :
# ces mods sont livres par Tools/BuildMod.ps1.
# ces mods sont livres par LifePlay-Sandbox/Tools/BuildMod.ps1 (addon, lot DC3).
$assetMods = @(Get-ChildItem (Join-Path $Build 'Windows\LifePlay\Mods') -Directory -ErrorAction SilentlyContinue | ForEach-Object Name)
if ($assetMods) { throw "Mod(s) d'assets installe(s) dans le build ($($assetMods -join ', ')) : utiliser un build neuf, ces mods sont livres par BuildMod.ps1" }

View file

@ -1,563 +0,0 @@
<#
.SYNOPSIS
Pilote de la chaine d'animations d'un pack, de la source au mod livre (lot AP7, Docs/Migration/20_Chaine_Animations.md, section 3).
.DESCRIPTION
-Pack <nom> : dossier AnimSources\<nom> ; la chaine est decrite par le bloc "chaine" de AnimSources\<nom>\pack.json.
Huit etapes, un controle bloquant a chacune, arret au premier controle en echec :
0 inventaire pack.json complet, licence / credit / auteur renseignes (refus de "A COMPLETER"), sources trouvees,
empreintes SHA-256 des .uasset de la source
1 import glb : script d'import (saute si clips presents et glb inchange) ; uasset : rien. Clips > 0
2 identif. LPAnimIdentify sur le clip de reference ; valide, methode non factice ; la methode est retenue
3 calque LPAnimEval -PoseOnly (pose T des corps) ; rapport.json valide
4 conversion BuildMod -Retarget bake (cmd /c start /wait) ; ligne RETARGET bridget n michael n (n = clips) ;
puis LPAnimTwist ; rapport_torsion.json valide
5 evaluation LPAnimEval complet (un clip rejete bloque), rapport.html, puis sandbox (vignettes et planches)
6 catalogue copie des clips dans le mod (DeliverAnims.ps1, ecrit livraison.json), LPAnimCatalog (fiches),
Catalogue.py (DeliverAnims.ps1 -CatalogueOnly). La copie precede les fiches : le controle exige
fiches_calculees.json plus recent que livraison.json
7 livraison cuisson DLC du mod (BuildMod.ps1 -Name <mod>) ; sans version de reference Releases\<version>\Windows :
avertissement, sans echec ; -SansCuisson la saute
Rapports : <travail>\etapes\<n>_<nom>.json (debut, fin, duree, commandes, code, controles, verdict) et
<travail>\etat.json (avancement, methode retenue, empreintes). -From <n> reprend a l'etape n (etat.json lu ;
etapes precedentes non rejouees) ; -To <n> s'arrete apres l'etape n. Un passage complet (sans -From) deplace
l'ancien contenu de <travail> dans <travail>\precedent_<date> (jamais supprime).
-DryRun : affiche le plan (commandes exactes, dans l'ordre), ne lance ni ne cree rien ; l'etape 0 y est evaluee
pour de bon et ses echecs sont montres comme "echec prevu" sans arreter le plan.
.EXAMPLE
.\Tools\anim\AnimPipeline.ps1 -Pack quaternius -DryRun
.\Tools\anim\AnimPipeline.ps1 -Pack freeanims
.\Tools\anim\AnimPipeline.ps1 -Pack freeanims -From 5 -SansCuisson
.\Tools\BuildMod.ps1 -Anim freeanims
#>
param(
[Parameter(Mandatory)][string] $Pack,
[ValidateRange(0, 7)][int] $From = 0,
[ValidateRange(0, 7)][int] $To = 7,
[switch] $DryRun,
[switch] $SansCuisson
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$Editor = 'D:\Game\Launcher\Engine\UE_5.5\Engine\Binaries\Win64\UnrealEditor-Cmd.exe'
$Pwsh = (Get-Process -Id $PID).Path
$Utf8 = New-Object Text.UTF8Encoding $false
$StepNames = 'inventaire', 'import', 'identification', 'calque', 'conversion', 'evaluation', 'catalogue', 'livraison'
# ---------------------------------------------------------------- etat du passage
$script:DryRun = [bool] $DryRun
$script:Cfg = $null # pack.json
$script:Ch = $null # bloc chaine
$script:Work = '' # dossier de travail (absolu, barres obliques)
$script:Method = '' # methode retenue (etape 2) ; vide tant qu'inconnue
$script:Clips = @() # clips source (noms), hors B_/M_ et exclus
$script:Etat = [ordered]@{ pack = $Pack; etapes = [ordered]@{} }
$script:PrevEtat = $null
$script:St = $null # etape en cours
$script:Planned = @() # echecs prevus (essai a blanc)
function Show-Rel([string] $Path) {
$p = $Path -replace '/', '\'
if ($p.StartsWith($Root, [StringComparison]::OrdinalIgnoreCase)) { return $p.Substring($Root.Length).TrimStart('\') }
$p
}
function Add-Cmd([string] $Text) {
$script:St.Cmds += $Text
Write-Host " > $Text"
}
function Add-Check([string] $Name, [bool] $Ok, [string] $Detail = '') {
$script:St.Checks += [ordered]@{ nom = $Name; ok = $Ok; detail = $Detail }
if ($Ok) { Write-Host " [ok] $Name$(if ($Detail) { " : $Detail" })" }
elseif ($script:DryRun) { Write-Host " [ECHEC PREVU] $Name$(if ($Detail) { " : $Detail" })" -ForegroundColor Yellow; $script:Planned += "etape $($script:St.N) : $Name$(if ($Detail) { " : $Detail" })" }
else { Write-Host " [ECHEC] $Name$(if ($Detail) { " : $Detail" })" -ForegroundColor Red }
}
function Add-Warn([string] $Text) {
$script:St.Warns += $Text
Write-Host " [avertissement] $Text" -ForegroundColor Yellow
}
function Read-Json([string] $Path) {
if (-not (Test-Path -LiteralPath $Path)) { return $null }
try { Get-Content -LiteralPath $Path -Raw -Encoding utf8 | ConvertFrom-Json } catch { $null }
}
function Write-Text([string] $Path, [string] $Text) {
New-Item -ItemType Directory -Force (Split-Path $Path -Parent) | Out-Null
[IO.File]::WriteAllText($Path, $Text, $Utf8)
}
function ConvertTo-Disk([string] $GamePath) {
if ($GamePath -notmatch '^/Game/(.+)$') { throw "chemin Unreal invalide (attendu /Game/...) : $GamePath" }
Join-Path $Root ('Content\' + ($Matches[1] -replace '/', '\'))
}
function Get-SourceClips {
$dir = ConvertTo-Disk $script:Ch.source.clips
if (-not (Test-Path $dir)) { return @() }
$excl = @($script:Cfg.exclus | Where-Object { $_ })
# Hors maillage et squelette importes avec les clips (glb : <maillage>, <maillage>_Skeleton, <maillage>_PhysicsAsset).
$mesh = ([string] $script:Ch.source.maillage -replace '^.*/', '' -replace '\..*$', '')
@(Get-ChildItem $dir -File -Filter '*.uasset' | ForEach-Object { $_.BaseName } |
Where-Object { $_ -notlike 'B_*' -and $_ -notlike 'M_*' -and $_ -notin $excl -and -not ($mesh -and $_ -like "$mesh*") -and $_ -notlike '*_Skeleton' -and $_ -notlike '*_PhysicsAsset' } | Sort-Object)
}
function Test-Placeholder($Value) {
$s = [string] $Value
(-not $s.Trim()) -or ($s -match 'A COMPLETER')
}
function Test-EditorClosed {
$running = @(Get-Process UnrealEditor, UnrealEditor-Cmd -ErrorAction SilentlyContinue)
Add-Check 'editeur ferme' ($running.Count -eq 0) $(if ($running) { "UnrealEditor ouvert (pid $($running[0].Id))" } else { '' })
$running.Count -eq 0
}
function Get-MethodArg { if ($script:Method) { $script:Method } elseif ($script:Ch.methode -and $script:Ch.methode -ne 'auto') { $script:Ch.methode } else { '<methode-etape-2>' } }
function Get-WorkRel { Show-Rel $script:Work }
# Commandlet via Tools\RunCommandlet.ps1 ; rend le code de sortie.
function Invoke-Commandlet([string] $Commandlet, [string] $CmdArgs) {
Add-Cmd "Tools\RunCommandlet.ps1 -Lot AP7 -Commandlet $Commandlet -Args '$CmdArgs'"
if ($script:DryRun) { return 0 }
$global:LASTEXITCODE = 0
& (Join-Path $Root 'Tools\RunCommandlet.ps1') -Lot AP7 -Commandlet $Commandlet -Args $CmdArgs | Out-Host
[int] $LASTEXITCODE
}
# Arguments communs a LPAnimEval / LPAnimTwist.
function Get-EvalArgs {
$bodies = (@($script:Ch.corps | ForEach-Object { '{0}:{1}' -f $_.nom, $_.prefixe })) -join ','
$templates = (@($script:Ch.corps | ForEach-Object { '{0}:{1}' -f $_.nom, $_.gabarit })) -join ','
'-Methode={0} -ClipDir={1} -RefClip={2} -BodyClipDir={3} -Templates={4} -Bodies={5}' -f (Get-MethodArg), $script:Ch.source.clips, $script:Ch.source.clip_reference, $script:Ch.sortie.clips, $templates, $bodies
}
function Get-WorkArg([string] $Name) { '-{0}="{1}"' -f $Name, $script:Work }
function Test-Fresh([string] $Path, [datetime] $Since) {
(Test-Path -LiteralPath $Path) -and ((Get-Item -LiteralPath $Path).LastWriteTime -ge $Since.AddSeconds(-2))
}
# ---------------------------------------------------------------- etapes
function Step-Inventaire {
$packFile = Join-Path $Root "AnimSources\$Pack\pack.json"
$script:Cfg = Read-Json $packFile
Add-Check 'pack.json lisible' ($null -ne $script:Cfg) (Show-Rel $packFile)
if (-not $script:Cfg) { throw "pack.json illisible : $packFile" }
$c = $script:Cfg.chaine
$script:Ch = $c
Add-Check 'bloc chaine present' ($null -ne $c)
if (-not $c) { throw 'bloc "chaine" absent de pack.json' }
$missing = @()
foreach ($k in 'methode', 'profil_attendu', 'mod', 'travail', 'sandbox') { if (Test-Placeholder $c.$k) { $missing += "chaine.$k" } }
foreach ($k in 'type', 'clips', 'maillage', 'clip_reference') { if (Test-Placeholder $c.source.$k) { $missing += "chaine.source.$k" } }
foreach ($k in 'clips', 'rigs') { if (Test-Placeholder $c.sortie.$k) { $missing += "chaine.sortie.$k" } }
if ($c.source.type -eq 'glb') { foreach ($k in 'fichier', 'import') { if (Test-Placeholder $c.source.$k) { $missing += "chaine.source.$k" } } }
elseif ($c.source.type -ne 'uasset') { $missing += "chaine.source.type ($($c.source.type) : glb ou uasset)" }
if (@($c.corps).Count -eq 0) { $missing += 'chaine.corps' }
foreach ($b in @($c.corps)) { foreach ($k in 'nom', 'maillage', 'prefixe', 'gabarit') { if (Test-Placeholder $b.$k) { $missing += "chaine.corps[$($b.nom)].$k" } } }
if (Test-Placeholder $script:Cfg.prefixe) { $missing += 'prefixe' }
Add-Check 'bloc chaine complet' ($missing.Count -eq 0) ($missing -join ', ')
$legal = @()
foreach ($k in 'licence', 'credit', 'auteur') { if (Test-Placeholder $script:Cfg.$k) { $legal += "$k = '$($script:Cfg.$k)'" } }
Add-Check 'licence, credit et auteur renseignes' ($legal.Count -eq 0) $(if ($legal) { 'refus : ' + ($legal -join ' ; ') } else { "$($script:Cfg.licence) / $($script:Cfg.auteur)" })
if ($missing.Count -gt 0) { throw 'bloc chaine incomplet' }
$script:Work = (Join-Path $Root ($c.travail -replace '/', '\')).TrimEnd('\') -replace '\\', '/'
$files = @()
if ($c.source.type -eq 'glb') {
$glb = [string] $c.source.fichier
$ok = Test-Path -LiteralPath $glb
Add-Check 'fichier glb trouve' $ok $glb
if ($ok) { $script:Etat.glb_sha256 = (Get-FileHash -LiteralPath $glb -Algorithm SHA256).Hash }
$imp = Join-Path $Root ($c.source.import -replace '/', '\')
Add-Check 'script d''import trouve' (Test-Path $imp) (Show-Rel $imp)
}
$dir = ConvertTo-Disk $c.source.clips
$dirOk = Test-Path $dir
if ($c.source.type -eq 'uasset') { Add-Check 'dossier des clips trouve' $dirOk (Show-Rel $dir) }
elseif (-not $dirOk) { Write-Host " (dossier des clips absent : cree par l'import) $(Show-Rel $dir)" }
$hashes = [ordered]@{}
if ($dirOk) {
foreach ($clip in Get-SourceClips) {
$hashes["$clip.uasset"] = (Get-FileHash -LiteralPath (Join-Path $dir "$clip.uasset") -Algorithm SHA256).Hash
}
}
if ($c.source.type -eq 'uasset') { Add-Check 'empreintes SHA-256 des .uasset' ($hashes.Count -gt 0) "$($hashes.Count) fichier(s)" }
else { Write-Host " empreintes SHA-256 : $($hashes.Count) .uasset deja presents + glb" }
$script:Etat.empreintes = $hashes
$script:St.Extra.empreintes = $hashes
if ($script:Etat.glb_sha256) { $script:St.Extra.glb_sha256 = $script:Etat.glb_sha256 }
}
function Step-Import {
$c = $script:Ch
if ($c.source.type -eq 'uasset') {
$script:St.Skipped = $true
Write-Host ' source uasset : rien a importer'
}
else {
$dir = ConvertTo-Disk $c.source.clips
$have = @(Get-SourceClips).Count
$prev = if ($script:PrevEtat) { $script:PrevEtat.glb_sha256 } else { $null }
$now = if ($script:Etat.glb_sha256) { $script:Etat.glb_sha256 } elseif (Test-Path -LiteralPath $c.source.fichier) { (Get-FileHash -LiteralPath $c.source.fichier -Algorithm SHA256).Hash } else { $null }
$script:Etat.glb_sha256 = $now
if ($have -gt 0 -and $prev -and $prev -eq $now) {
$script:St.Skipped = $true
Write-Host " import saute : $have clip(s) presents, glb inchange (SHA-256 $($now.Substring(0, 12))...)"
}
else {
$py = (Join-Path $Root ($c.source.import -replace '/', '\')) -replace '\\', '/'
$log = Join-Path $Root ('Saved\Logs\portage\AP7_Import_{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
Add-Cmd "UnrealEditor-Cmd.exe `"$Root\LifePlay.uproject`" -run=pythonscript `"-script=$py`" -unattended -nopause -nosplash -stdout -FullStdOutLogOutput > $(Show-Rel $log)"
if (-not $script:DryRun) {
if (-not (Test-EditorClosed)) { throw 'editeur ouvert' }
New-Item -ItemType Directory -Force (Split-Path $log -Parent) | Out-Null
& $Editor (Join-Path $Root 'LifePlay.uproject') -run=pythonscript "-script=$py" -unattended -nopause -nosplash -stdout -FullStdOutLogOutput *> $log
$script:St.Code = $LASTEXITCODE
$imported = Select-String -Path $log -Pattern 'QUATERNIUS dossier \d+ asset' -Quiet
Add-Check 'import termine (journal)' $imported (Show-Rel $log)
}
}
}
if (-not $script:DryRun) {
$n = @(Get-SourceClips).Count
Add-Check 'clips source > 0' ($n -gt 0) "$n clip(s)"
$script:Clips = Get-SourceClips
}
else { Write-Host " clips source actuellement : $(@(Get-SourceClips).Count)" }
}
function Step-Identification {
$c = $script:Ch
$clip = '{0}/{1}' -f $c.source.clips.TrimEnd('/'), $c.source.clip_reference
$out = "$($script:Work)/identification.json"
$code = Invoke-Commandlet 'LPAnimIdentify' ('-Clip={0} -Out="{1}" -Attendu={2}' -f $clip, $out, $c.profil_attendu)
if ($script:DryRun) {
if ($c.methode -and $c.methode -ne 'auto') { Write-Host " methode imposee par pack.json : $($c.methode)" } else { Write-Host ' methode retenue : celle de identification.json (champ methode)' }
return
}
$script:St.Code = $code
Add-Check 'LPAnimIdentify code 0' ($code -eq 0) "code $code"
$r = Read-Json $out
Add-Check 'identification.json valide' ($r -and $r.valide -eq $true) $(if ($r) { "profil $($r.profil), methode $($r.methode)" } else { 'rapport absent' })
Add-Check 'methode non factice' ($r -and $r.methode -and $r.methode_factice -ne $true) $(if ($r) { "methode '$($r.methode)'" })
if ($c.methode -and $c.methode -ne 'auto') { $script:Method = [string] $c.methode; Write-Host " methode imposee par pack.json : $($script:Method)" }
elseif ($r -and $r.methode) { $script:Method = [string] $r.methode }
$script:Etat.methode = $script:Method
$script:St.Extra.methode = $script:Method
}
function Step-Calque {
if (-not $script:DryRun -and -not (Test-EditorClosed)) { throw 'editeur ouvert' }
$a = (Get-EvalArgs) + " -PoseOnly -PoseDir=`"$($script:Work)/pose`" " + (Get-WorkArg 'Out')
$code = Invoke-Commandlet 'LPAnimEval' $a
if ($script:DryRun) { return }
$script:St.Code = $code
$r = Read-Json (Join-Path $script:Work 'rapport.json')
Add-Check 'rapport.json valide (pose T)' ($r -and $r.valide -eq $true) $(if ($r -and $r.echecs) { (@($r.echecs) -join '; ') } elseif (-not $r) { 'rapport absent' } else { "code $code" })
foreach ($b in @($script:Ch.corps)) {
Add-Check "retarget_pose_$($b.nom).txt ecrit" (Test-Path (Join-Path $script:Work "pose\retarget_pose_$($b.nom).txt"))
}
}
function Step-Conversion {
$c = $script:Ch
$cfgFile = "$($script:Work)/anim_retarget.json"
$cfg = [ordered]@{
mode = 'bake'; methode = (Get-MethodArg); source_mesh = $c.source.maillage; clips_dir = $c.source.clips
out_dir = $c.sortie.clips; dest = $c.sortie.rigs
corps = @($c.corps | ForEach-Object { [ordered]@{ nom = $_.nom; maillage = $_.maillage; prefixe = $_.prefixe } })
exclure = @($script:Cfg.exclus | Where-Object { $_ })
pose_dir = "$($script:Work)/pose"
}
$log = "$($script:Work)/etapes/4_conversion_bake.log"
$bat = "$($script:Work)/etapes/4_conversion_bake.cmd"
$buildMod = Join-Path $Root 'Tools\BuildMod.ps1'
$line = "@`"$Pwsh`" -NoProfile -ExecutionPolicy Bypass -File `"$buildMod`" -Retarget bake -RetargetOnly -RetargetConfig `"$($cfgFile -replace '/', '\')`" > `"$($log -replace '/', '\')`" 2>&1"
Write-Host " config de cuisson : $(Show-Rel $cfgFile)"
Add-Cmd "cmd /c start `"`" /wait `"$(Show-Rel $bat)`" (contenu : $(Show-Rel $buildMod) -Retarget bake -RetargetOnly -RetargetConfig $(Show-Rel $cfgFile) > $(Show-Rel $log))"
if (-not $script:DryRun) {
if (-not (Test-EditorClosed)) { throw 'editeur ouvert' }
Write-Text $cfgFile (($cfg | ConvertTo-Json -Depth 10) + "`r`n")
Write-Text $bat ($line + "`r`n@exit /b %ERRORLEVEL%`r`n")
# start /wait sur un .cmd laisse sa console ouverte (cmd /K) et ne rend jamais la main : passer par cmd /c (AP7).
$p = Start-Process cmd -ArgumentList ('/c start "" /wait cmd /c "{0}"' -f ($bat -replace '/', '\')) -Wait -PassThru -NoNewWindow
$script:St.Code = $p.ExitCode
$text = if (Test-Path $log) { Get-Content $log -Raw -Encoding utf8 } else { '' }
$n = @(Get-SourceClips).Count
$m = [regex]::Match($text, 'RETARGET bridget (\d+) michael (\d+)')
Add-Check 'conversion : code 0' ($script:St.Code -eq 0) "code $($script:St.Code)"
Add-Check "ligne RETARGET bridget $n michael $n" ($m.Success -and [int] $m.Groups[1].Value -eq $n -and [int] $m.Groups[2].Value -eq $n) $(if ($m.Success) { $m.Value } else { 'ligne absente : voir ' + (Show-Rel $log) })
if (-not $m.Success) { throw 'conversion non confirmee' }
}
$a = (Get-EvalArgs) + ' ' + (Get-WorkArg 'Out')
$code = Invoke-Commandlet 'LPAnimTwist' $a
if ($script:DryRun) { return }
$r = Read-Json (Join-Path $script:Work 'rapport_torsion.json')
Add-Check 'rapport_torsion.json valide' ($r -and $r.valide -eq $true) $(if (-not $r) { "rapport absent (code $code)" } else { '' })
}
# Sandbox : vignettes et planches. Fichiers de Saved\ remplaces le temps de la tournee, puis deplaces ou restaures.
function Invoke-Sandbox {
$c = $script:Ch
$saved = Join-Path $Root 'Saved'
$ini = Join-Path $saved 'sandbox_pack.ini'
$modeF = Join-Path $saved 'sandbox_mode.txt'
$tourF = Join-Path $saved 'sandbox_tour.txt'
$bat = Join-Path $Root 'Tools\debug\LifePlay_interaction_sandbox.bat'
$sbRel = ($c.sortie.clips -replace '^/Game/', '')
$clipRel = ($c.source.clips -replace '^/Game/', '')
$sbRoot = [string] $c.sandbox
$iniText = @(
'; Ecrit par Tools\anim\AnimPipeline.ps1 (lot AP7)', '[Sandbox.Pack]', "Name=$Pack", "Method=$(Get-MethodArg)",
"ClipRoot=$($c.source.clips)", "ClipDisk=$clipRel", "BodyClipRoot=$($c.sortie.clips)", "BodyClipDisk=$sbRel",
"RefClip=$($c.source.clip_reference)", "MannequinMesh=$($c.source.maillage)", '', '[Sandbox.Output]', "MeasureOutputRoot=$sbRoot") -join "`r`n"
$outDir = Join-Path $saved ($sbRoot -replace '/', '\')
Add-Cmd "ecrire Saved\sandbox_pack.ini (Name=$Pack, Method=$(Get-MethodArg), ClipRoot=$($c.source.clips), BodyClipRoot=$($c.sortie.clips), RefClip=$($c.source.clip_reference), MeasureOutputRoot=$sbRoot)"
Add-Cmd 'ecrire Saved\sandbox_mode.txt = Mesure ; Saved\sandbox_tour.txt = vignettes planches quitter'
Add-Cmd "Tools\debug\LifePlay_interaction_sandbox.bat (attente de la fermeture de UnrealEditor, 40 min maximum)"
Add-Cmd "deplacer Saved\sandbox_pack.ini vers $(Get-WorkRel)\sandbox_pack.ini ; restaurer sandbox_mode.txt d'origine"
if ($script:DryRun) { Write-Host " controle : <Clip>.png dans $(Show-Rel (Join-Path $outDir 'vignettes')) ; <Clip>.jpg et <Clip>.json dans $(Show-Rel (Join-Path $outDir 'planches')), par clip"; return }
if (-not (Test-EditorClosed)) { throw 'editeur ouvert' }
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$t0 = Get-Date
New-Item -ItemType Directory -Force $script:Work | Out-Null
$origMode = if (Test-Path $modeF) { Get-Content $modeF -Raw } else { $null }
foreach ($f in $ini, $tourF) {
if (Test-Path $f) { Move-Item $f (Join-Path $script:Work ("{0}.avant_{1}" -f (Split-Path $f -Leaf), $stamp)) }
}
try {
Write-Text $ini ($iniText + "`r`n")
Write-Text $modeF 'Mesure'
Write-Text $tourF 'vignettes planches quitter'
# Sans tuyau ni attente : avec `& $bat | Out-Null`, PowerShell attendait la fin de l'editeur lance par le .bat
# (heritier de la sortie), puis ne le trouvait plus et concluait a tort « pas lancee » (AP7).
Start-Process -FilePath 'cmd.exe' -ArgumentList ('/c "{0}"' -f $bat) -WindowStyle Hidden | Out-Null
$deadline = (Get-Date).AddMinutes(3)
while (-not (Get-Process UnrealEditor -ErrorAction SilentlyContinue) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 3 }
if (-not (Get-Process UnrealEditor -ErrorAction SilentlyContinue)) { throw 'la sandbox ne s''est pas lancee (3 min)' }
$deadline = (Get-Date).AddMinutes(40)
while ((Get-Process UnrealEditor -ErrorAction SilentlyContinue) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 10 }
if (Get-Process UnrealEditor -ErrorAction SilentlyContinue) { throw 'la sandbox ne s''est pas fermee en 40 min (laissee ouverte)' }
}
finally {
if (Test-Path $ini) { Move-Item $ini (Join-Path $script:Work 'sandbox_pack.ini') -Force }
if (Test-Path $tourF) { Move-Item $tourF (Join-Path $script:Work 'sandbox_tour.txt') -Force }
if (Test-Path $modeF) { Move-Item $modeF (Join-Path $script:Work 'sandbox_mode.txt') -Force }
if ($null -ne $origMode) { Write-Text $modeF $origMode }
}
$missV = @(); $missP = @()
foreach ($clip in (Get-SourceClips)) {
if (-not (Test-Fresh (Join-Path $outDir "vignettes\$clip.png") $t0)) { $missV += $clip }
if (-not ((Test-Fresh (Join-Path $outDir "planches\$clip.jpg") $t0) -and (Test-Fresh (Join-Path $outDir "planches\$clip.json") $t0))) { $missP += $clip }
}
Add-Check 'une vignette <Clip>.png par clip' ($missV.Count -eq 0) $(if ($missV) { 'manquantes : ' + (($missV | Select-Object -First 8) -join ', ') })
Add-Check 'une planche <Clip>.jpg + .json par clip' ($missP.Count -eq 0) $(if ($missP) { 'manquantes : ' + (($missP | Select-Object -First 8) -join ', ') })
}
function Step-Evaluation {
if (-not $script:DryRun -and -not (Test-EditorClosed)) { throw 'editeur ouvert' }
$a = (Get-EvalArgs) + " -PoseDir=`"$($script:Work)/pose`" " + (Get-WorkArg 'Out')
$code = Invoke-Commandlet 'LPAnimEval' $a
Add-Cmd "python Tools\anim\RapportControles.py $(Get-WorkRel)"
if (-not $script:DryRun) {
$script:St.Code = $code
$r = Read-Json (Join-Path $script:Work 'rapport.json')
Add-Check 'rapport.json valide (aucun clip rejete)' ($r -and $r.valide -eq $true) $(if ($r -and $r.echecs) { (@($r.echecs) -join '; ') } elseif (-not $r) { 'rapport absent' })
if ($r) { foreach ($x in @($r.a_revoir)) { Add-Warn "a revoir : $x" } }
if (-not ($r -and $r.valide -eq $true)) { throw 'evaluation en echec' }
& python (Join-Path $Root 'Tools\anim\RapportControles.py') $script:Work | Out-Host
Add-Check 'rapport.html ecrit' ((Test-Path (Join-Path $script:Work 'rapport.html')) -and $LASTEXITCODE -eq 0)
}
Invoke-Sandbox
}
function Get-DeliverParams([switch] $CatalogueOnly) {
$c = $script:Ch
$outDir = Join-Path (Join-Path $Root 'Saved') (($c.sandbox -replace '/', '\'))
$p = [ordered]@{
Name = $c.mod; Source = $c.sortie.clips; Prefix = $script:Cfg.prefixe; Exclude = (@($script:Cfg.exclus | Where-Object { $_ }) -join ',')
Pipeline = (Get-WorkRel); Pack = (Join-Path $Root "AnimSources\$Pack\pack.json")
}
if ($CatalogueOnly) {
$p.Vignettes = Join-Path $outDir 'vignettes'; $p.Planches = Join-Path $outDir 'planches'; $p.CatalogueOnly = $true
}
$p
}
function Format-Params($P) { (@($P.GetEnumerator() | ForEach-Object { if ($_.Value -is [bool]) { "-$($_.Key)" } else { "-$($_.Key) '$($_.Value)'" } })) -join ' ' }
function Invoke-Deliver($P) {
Add-Cmd "Tools\anim\DeliverAnims.ps1 $(Format-Params $P)"
if ($script:DryRun) { return 0 }
$global:LASTEXITCODE = 0
& (Join-Path $Root 'Tools\anim\DeliverAnims.ps1') @P | Out-Host
[int] $LASTEXITCODE
}
function Step-Catalogue {
$c = $script:Ch
$liv = Join-Path $script:Work 'livraison.json'
$fic = Join-Path $script:Work 'fiches_calculees.json'
$t0 = Get-Date
# a. copie des clips dans le mod (ecrit livraison.json)
$code = Invoke-Deliver (Get-DeliverParams)
if (-not $script:DryRun) {
$script:St.Code = $code
Add-Check 'livraison (copie dans le mod) : code 0' ($code -eq 0) "code $code"
Add-Check 'livraison.json ecrit' (Test-Fresh $liv $t0)
if ($code -ne 0 -or -not (Test-Fresh $liv $t0)) { throw 'livraison en echec' }
if (-not (Test-EditorClosed)) { throw 'editeur ouvert' }
}
# b. fiches calculees
$code = Invoke-Commandlet 'LPAnimCatalog' ('-Livraison="{0}" -Out="{1}"' -f "$($script:Work)/livraison.json", $script:Work)
if (-not $script:DryRun) {
$f = Read-Json $fic
$newer = (Test-Path $fic) -and (Test-Path $liv) -and ((Get-Item $fic).LastWriteTime -gt (Get-Item $liv).LastWriteTime)
Add-Check 'fiches_calculees.json plus recent que livraison.json' $newer
Add-Check 'fiches calculees sans echec' ($f -and @($f.echecs).Count -eq 0 -and $code -eq 0) $(if ($f -and $f.echecs) { (@($f.echecs) -join '; ') } else { "code $code" })
if (-not $newer) { throw 'fiches non calculees' }
}
# c. catalogue (Catalogue.py)
$code = Invoke-Deliver (Get-DeliverParams -CatalogueOnly)
if (-not $script:DryRun) {
$r = Read-Json (Join-Path $script:Work 'catalogue_rapport.json')
$cat = Join-Path $Root "ModSources\$($c.mod)\Module\Animations\catalogue.json"
Add-Check 'Catalogue.py : code 0' ($code -eq 0) "code $code"
Add-Check 'catalogue_rapport.json ok' ($r -and $r.ok -eq $true) $(if ($r) { "$($r.fiches) fiche(s)" })
Add-Check 'catalogue.json dans le mod' (Test-Path $cat) (Show-Rel $cat)
}
}
function Step-Livraison {
$c = $script:Ch
$version = (Select-String -Path (Join-Path $Root 'Config\DefaultGame.ini') -Pattern '^ProjectVersion=(.+)$' | Select-Object -First 1).Matches[0].Groups[1].Value.Trim()
$release = Join-Path $Root "Releases\$version\Windows\AssetRegistry.bin"
$log = "$($script:Work)/etapes/7_livraison_cuisson.log"
if ($SansCuisson) {
$script:St.Skipped = $true
Write-Host ' -SansCuisson : cuisson DLC sautee'
return
}
if (-not (Test-Path $release)) {
Add-Cmd "(saute) Tools\BuildMod.ps1 -Name $($c.mod) version de reference absente : $(Show-Rel $release)"
Add-Warn 'version de reference absente : cuisson DLC non faite'
return
}
Add-Cmd "$(Split-Path $Pwsh -Leaf) -NoProfile -File Tools\BuildMod.ps1 -Name $($c.mod) > $(Show-Rel $log) (version de reference $version)"
if ($script:DryRun) { return }
New-Item -ItemType Directory -Force (Split-Path $log -Parent) | Out-Null
& $Pwsh -NoProfile -ExecutionPolicy Bypass -File (Join-Path $Root 'Tools\BuildMod.ps1') -Name $c.mod *>&1 | Tee-Object -FilePath $log | Out-Host
$script:St.Code = $LASTEXITCODE
$text = Get-Content $log -Raw -Encoding utf8
$m = [regex]::Match($text, 'Archive : (.+?\.zip) \(')
Add-Check 'BuildMod : code 0' ($script:St.Code -eq 0) "code $($script:St.Code)"
Add-Check 'archive .zip produite' ($m.Success -and (Test-Path $m.Groups[1].Value)) $(if ($m.Success) { $m.Groups[1].Value })
}
# ---------------------------------------------------------------- moteur
function Save-Etat {
if ($script:DryRun) { return }
$script:Etat.maj = Get-Date -Format s
Write-Text (Join-Path $script:Work 'etat.json') (($script:Etat | ConvertTo-Json -Depth 10) + "`r`n")
}
function Move-Previous {
$w = $script:Work
$keep = @(Get-ChildItem $w -Force -ErrorAction SilentlyContinue | Where-Object { -not ($_.PSIsContainer -and $_.Name -like 'precedent_*') })
if (-not $keep.Count) { return }
$dest = Join-Path $w ("precedent_{0}" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
New-Item -ItemType Directory -Force $dest | Out-Null
foreach ($k in $keep) { Move-Item -LiteralPath $k.FullName -Destination $dest }
Write-Host " resultat precedent deplace : $($keep.Count) element(s) -> $(Show-Rel $dest)"
}
function Invoke-Step([int] $N, [scriptblock] $Block) {
if ($N -lt $From -or $N -gt $To) { return }
$name = $StepNames[$N]
Write-Host ''
Write-Host "== Etape $N : $name"
$script:St = [ordered]@{ N = $N; Cmds = @(); Checks = @(); Warns = @(); Extra = [ordered]@{}; Code = $null; Skipped = $false }
$t0 = Get-Date
try { $null = & $Block }
catch {
$msg = $_.Exception.Message
$script:St.Checks += [ordered]@{ nom = 'erreur'; ok = $false; detail = $msg }
if ($script:DryRun) { $script:Planned += "etape $N : $msg"; Write-Host " [ECHEC PREVU] $msg" -ForegroundColor Yellow }
else { Write-Host " [ECHEC] $msg" -ForegroundColor Red }
}
$bad = @($script:St.Checks | Where-Object { $_.ok -eq $false })
$verdict = if ($bad.Count) { 'echec' } elseif ($script:St.Skipped) { 'saute' } elseif ($script:St.Warns.Count) { 'avertissement' } else { 'ok' }
if ($script:DryRun) { return $(if ($N -eq 0 -and -not $script:Ch) { 'stop' }) }
if (-not $script:Work) { throw "Etape $N ($name) en echec : $((@($bad | ForEach-Object { $_.detail })) -join ' ; ')" }
# Passage complet : l'ancien resultat est deplace une fois l'inventaire accepte (un refus ne touche a rien).
$recName = "${N}_$name"
if ($N -eq 0 -and $From -eq 0) {
if ($bad.Count) { $recName = "${N}_${name}_refus_$(Get-Date -Format 'yyyyMMdd-HHmmss')" } else { Move-Previous }
}
$t1 = Get-Date
$rec = [ordered]@{
etape = $N; nom = $name; debut = $t0.ToString('s'); fin = $t1.ToString('s'); duree_s = [int] ($t1 - $t0).TotalSeconds
commandes = $script:St.Cmds; code = $script:St.Code; controles = $script:St.Checks; avertissements = $script:St.Warns; verdict = $verdict
}
foreach ($k in $script:St.Extra.Keys) { $rec[$k] = $script:St.Extra[$k] }
Write-Text (Join-Path $script:Work "etapes\$recName.json") (($rec | ConvertTo-Json -Depth 10) + "`r`n")
$script:Etat.etapes["$N"] = [ordered]@{ nom = $name; verdict = $verdict; fin = $t1.ToString('s') }
Save-Etat
Write-Host " verdict : $verdict ($($rec.duree_s) s)"
if ($verdict -eq 'echec') {
throw "Etape $N ($name) en echec : " + ((@($bad | ForEach-Object { "$($_.nom)$(if ($_.detail) { " ($($_.detail))" })" })) -join ' ; ')
}
}
try {
if ($Pack -notmatch '^[A-Za-z0-9_-]+$') { throw "nom de pack invalide : $Pack" }
if ($From -gt $To) { throw "-From ($From) apres -To ($To)" }
Write-Host "Pilote AnimPipeline : pack $Pack, etapes $From a $To$(if ($DryRun) { ' (A BLANC : rien lance, rien cree)' })"
# Le bloc chaine est lu des l'etape 0 ; pour -From > 0 il faut le charger sans rejouer l'inventaire.
if ($From -gt 0) {
$script:Cfg = Read-Json (Join-Path $Root "AnimSources\$Pack\pack.json")
if (-not $script:Cfg -or -not $script:Cfg.chaine) { throw "pack.json illisible ou sans bloc chaine (AnimSources\$Pack)" }
$script:Ch = $script:Cfg.chaine
$script:Work = (Join-Path $Root ($script:Ch.travail -replace '/', '\')).TrimEnd('\') -replace '\\', '/'
$prev = if (Test-Path (Join-Path $script:Work 'etat.json')) { Get-Content (Join-Path $script:Work 'etat.json') -Raw -Encoding utf8 | ConvertFrom-Json -AsHashtable } else { $null }
if ($prev) { $script:Etat = $prev; $script:PrevEtat = $prev; if ($prev.methode) { $script:Method = [string] $prev.methode } }
if (-not $script:Method -and $script:Ch.methode -and $script:Ch.methode -ne 'auto') { $script:Method = [string] $script:Ch.methode }
for ($i = 0; $i -lt $From; $i++) {
$e = if ($prev -and $prev.etapes) { $prev.etapes["$i"] } else { $null }
if (-not $e -or $e.verdict -notin 'ok', 'saute', 'avertissement') {
if ($DryRun) { Write-Host " (etape $i : pas de verdict enregistre)" } else { Write-Warning "etape $i : pas de verdict enregistre dans etat.json (reprise sur la foi de l'utilisateur)" }
}
}
if ($From -ge 3 -and -not $script:Method -and -not $DryRun) { throw "methode inconnue (etat.json sans methode et pack.json = auto) : reprendre depuis l'etape 2" }
$script:Clips = Get-SourceClips
}
else {
# Passage complet : etat precedent lu avant tout deplacement.
$cfgPeek = Read-Json (Join-Path $Root "AnimSources\$Pack\pack.json")
if ($cfgPeek -and $cfgPeek.chaine.travail) {
$w = (Join-Path $Root ($cfgPeek.chaine.travail -replace '/', '\')).TrimEnd('\')
$ef = Join-Path $w 'etat.json'
if (Test-Path $ef) { $script:PrevEtat = Get-Content $ef -Raw -Encoding utf8 | ConvertFrom-Json -AsHashtable }
$keep = @(Get-ChildItem $w -Force -ErrorAction SilentlyContinue | Where-Object { -not ($_.PSIsContainer -and $_.Name -like 'precedent_*') })
if ($keep.Count) { Write-Host "Resultat precedent : $($keep.Count) element(s) de $(Show-Rel $w) a deplacer dans $(Show-Rel $w)\precedent_<date> apres l'inventaire accepte" }
}
}
$stop = $null
$stop = Invoke-Step 0 { Step-Inventaire }
if ($stop -eq 'stop') { throw 'plan interrompu : pack.json illisible ou sans bloc chaine' }
Invoke-Step 1 { Step-Import }
Invoke-Step 2 { Step-Identification }
Invoke-Step 3 { Step-Calque }
Invoke-Step 4 { Step-Conversion }
Invoke-Step 5 { Step-Evaluation }
Invoke-Step 6 { Step-Catalogue }
Invoke-Step 7 { Step-Livraison }
Write-Host ''
if ($DryRun) {
if ($script:Planned.Count) {
Write-Host "A blanc : plan complet affiche ; $($script:Planned.Count) echec(s) prevu(s) :" -ForegroundColor Yellow
$script:Planned | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow }
}
else { Write-Host 'A blanc : plan complet affiche, aucun echec prevu' }
}
else { Write-Host "Chaine terminee : pack $Pack, etapes $From a $To (rapports : $(Get-WorkRel)\etapes)" }
exit 0
}
catch {
Write-Host "ECHEC : $($_.Exception.Message)" -ForegroundColor Red
exit 1
}

View file

@ -1,463 +0,0 @@
#!/usr/bin/env python3
"""Catalogue des animations d'un mod (lot AP6) : catalogue.json, catalogue.html, vignettes.
Entrees : pack.json (--pack), livraison.json + fiches_calculees.json + rapport.json + csv de qualite (dossier --pipeline),
vignettes <Clip>.png (--vignettes, carres 512 px, defaut Saved/Measurement/vignettes), planches <Clip>.jpg + .json
(--planches, defaut Saved/Measurement/planches ; bande de N images carrees de 256 px, animee au survol),
clips du mod ModSources/<mod>/Content/Anims (ou --mod-dir). Collisions de cles : jeu de base, scenarios du jeu et
catalogues de tous les autres mods de ModSources/*/Module/Animations/catalogue.json.
Controles bloquants : code de sortie 1 et liste des causes ; rien n'est ecrit dans --out en cas d'echec.
Python 3 standard, sans dependance.
"""
import argparse
import csv
import datetime
import hashlib
import html
import json
import os
import re
import shutil
import sys
BASE_KEYS = ["martialart", "sneak", "dance", "smoke", "write", "drinkbottle", "drinkglass", "drinktea",
"camera", "call", "browse", "makeup", "eat", "sweep", "fightlost", "gun", "knockedout"]
TRIAL_MODULE = "lpr_anim_essai"
SENS_FR = {"sur place": "sur place", "avant": "avance", "arriere": "recule", "cote": "de côté"}
def read_json(path):
with open(path, encoding="utf-8-sig") as f:
return json.load(f)
def sha256_of(paths):
h = hashlib.sha256()
for p in paths:
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest().upper()
def read_csv_rows(path):
if not os.path.isfile(path):
return []
with open(path, encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
def num(v):
try:
return float(v)
except (TypeError, ValueError):
return 0.0
def game_keys(root):
"""Cles deja utilisees par le jeu (hors module d'essai) : ANIMATION: <cle> et animate(<cle>)."""
keys = set()
mods = os.path.join(root, "Content", "Modules")
pat = re.compile(r"ANIMATION:\s*([A-Za-z0-9_]+)|animate\(\s*([A-Za-z0-9_]+)\s*\)")
for dp, dn, fn in os.walk(mods):
rel = os.path.relpath(dp, mods).replace("\\", "/")
if rel.split("/")[0] == TRIAL_MODULE:
dn[:] = []
continue
for name in fn:
if name.lower().endswith((".lpaction", ".lpscene")):
try:
with open(os.path.join(dp, name), encoding="utf-8-sig", errors="replace") as f:
text = f.read()
except OSError:
continue
for m in pat.finditer(text):
keys.add((m.group(1) or m.group(2)).lower())
return keys
def other_catalogue_keys(root, mod, out_dir):
keys = {}
base = os.path.join(root, "ModSources")
if not os.path.isdir(base):
return keys
own = os.path.normcase(os.path.abspath(os.path.join(out_dir, "catalogue.json")))
for d in sorted(os.listdir(base)):
p = os.path.join(base, d, "Module", "Animations", "catalogue.json")
if d == mod or not os.path.isfile(p) or os.path.normcase(os.path.abspath(p)) == own:
continue
try:
for a in read_json(p).get("animations", []):
keys[str(a.get("cle", "")).lower()] = d
except (ValueError, OSError):
pass
return keys
def build(args, errors):
root = os.path.abspath(args.root)
pipe = os.path.abspath(args.pipeline)
pack_path = os.path.abspath(args.pack)
out = os.path.abspath(args.out)
mod_dir = os.path.abspath(args.mod_dir) if args.mod_dir else os.path.join(root, "ModSources", args.mod)
vign = os.path.abspath(args.vignettes) if args.vignettes else os.path.join(root, "Saved", "Measurement", "vignettes")
plan = os.path.abspath(args.planches) if args.planches else os.path.join(root, "Saved", "Measurement", "planches")
def load(path, what):
if not os.path.isfile(path):
errors.append("%s introuvable : %s" % (what, path))
return None
try:
return read_json(path)
except ValueError as e:
errors.append("%s illisible : %s (%s)" % (what, path, e))
return None
pack = load(pack_path, "pack.json")
liv_path = os.path.join(pipe, "livraison.json")
fic_path = os.path.join(pipe, "fiches_calculees.json")
liv = load(liv_path, "livraison.json")
fic = load(fic_path, "fiches_calculees.json")
rap = load(os.path.join(pipe, "rapport.json"), "rapport.json")
if None in (pack, liv, fic, rap):
return None
# Rapport et fiches calculees.
if rap.get("valide") is not True:
errors.append("rapport.json non valide : %s" % "; ".join(map(str, rap.get("echecs") or [])))
if fic.get("echecs"):
errors.append("fiches_calculees.json contient des echecs : %s" % "; ".join(map(str, fic["echecs"])))
if os.path.getmtime(fic_path) <= os.path.getmtime(liv_path):
errors.append("fiches_calculees.json (%s) n'est pas plus recent que livraison.json (%s) : "
"le catalogue ne decrit pas les clips livres"
% (datetime.datetime.fromtimestamp(os.path.getmtime(fic_path)).isoformat(" ", "seconds"),
datetime.datetime.fromtimestamp(os.path.getmtime(liv_path)).isoformat(" ", "seconds")))
fiches = fic.get("fiches", {})
declared = pack.get("animations", {})
# Cles : uniques, sans collision.
keys = [str(e.get("cle", "")) for e in liv]
seen = set()
for k in keys:
if k in seen:
errors.append("cle en double dans livraison.json : %s" % k)
seen.add(k)
taken = {k: "jeu de base" for k in BASE_KEYS}
for k in game_keys(root):
taken.setdefault(k, "scenario du jeu")
for k, d in other_catalogue_keys(root, args.mod, out).items():
taken.setdefault(k, "catalogue de %s" % d)
for k in keys:
if k.lower() in taken:
errors.append("collision de cle : %s deja utilisee (%s)" % (k, taken[k.lower()]))
# Qualite.
a_revoir = {}
for line in rap.get("a_revoir") or []:
parts = [p.strip() for p in str(line).split(" : ", 2)]
if len(parts) == 3:
a_revoir.setdefault(parts[1], []).append("%s : %s" % (parts[0], parts[2]))
qual = {}
for corps in ("Bridget", "Michael"):
for r in read_csv_rows(os.path.join(pipe, "eval_%s.csv" % corps)):
q = qual.setdefault(r["clip"], {"brut": 0.0, "torsion": 0.0})
q["brut"] = max(q["brut"], num(r.get("brut_pire")))
q["torsion"] = max(q["torsion"], num(r.get("torsion_deg")))
animations = []
thumbs = []
for e in liv:
k = str(e.get("cle", ""))
d = declared.get(k)
f = fiches.get(k)
if d is None:
errors.append("%s : absente de pack.json" % k)
if f is None:
errors.append("%s : fiche calculee absente" % k)
if d is None or f is None:
continue
clip = d.get("clip", "")
miss = []
for name, val in (("clip", clip), ("nom.fr", (d.get("nom") or {}).get("fr")), ("nom.en", (d.get("nom") or {}).get("en")),
("categorie", d.get("categorie")), ("style", d.get("style")),
("usage.fr", (d.get("usage") or {}).get("fr")), ("usage.en", (d.get("usage") or {}).get("en"))):
if not val:
miss.append(name)
for name in ("images", "cadence", "duree_s", "boucle", "deplacement_cm", "vitesse_cm_s", "deplacement_sens",
"posture", "pieds_au_sol_pct", "mains"):
if f.get(name) in (None, "", {}):
miss.append("fiche." + name)
for name in ("source", "licence", "credit"):
if not pack.get({"source": "source", "licence": "licence", "credit": "credit"}[name]):
miss.append("pack." + name)
for name in ("asset_F", "asset_M"):
if not e.get(name):
miss.append("livraison." + name)
if miss:
errors.append("%s : champs obligatoires vides : %s" % (k, ", ".join(miss)))
thumb = os.path.join(vign, "%s.png" % clip)
if not clip or not os.path.isfile(thumb):
errors.append("%s : vignette absente (%s)" % (k, thumb))
pj = os.path.join(plan, "%s.jpg" % clip)
pm = os.path.join(plan, "%s.json" % clip)
pinfo = None
if not clip or not os.path.isfile(pj):
errors.append("%s : planche absente (%s)" % (k, pj))
elif not os.path.isfile(pm):
errors.append("%s : description de planche absente (%s)" % (k, pm))
else:
try:
pinfo = read_json(pm)
n_img = pinfo["images"]
if isinstance(n_img, bool) or not isinstance(n_img, int) or n_img < 2:
errors.append("%s : planche invalide, images = %r (au moins 2 attendues) : %s" % (k, n_img, pm))
pinfo = None
elif pinfo.get("taille") != 256:
errors.append("%s : planche invalide, taille = %r (256 attendu) : %s" % (k, pinfo.get("taille"), pm))
pinfo = None
except (ValueError, KeyError, TypeError, AttributeError) as e:
errors.append("%s : description de planche illisible : %s (%r)" % (k, pm, e))
pinfo = None
files = [os.path.join(mod_dir, "Content", "Anims", "Female", k + "_F.uasset"),
os.path.join(mod_dir, "Content", "Anims", "Male", k + "_M.uasset")]
for p in files:
if not os.path.isfile(p):
errors.append("%s : clip du mod absent : %s" % (k, p))
if miss or not os.path.isfile(files[0]) or not os.path.isfile(files[1]) or not os.path.isfile(thumb) or pinfo is None:
continue # causes deja listees ci-dessus
tf, tm = os.path.getsize(files[0]), os.path.getsize(files[1])
motifs = a_revoir.get(clip, [])
q = qual.get(clip, {"brut": 0.0, "torsion": 0.0})
decl = d.get("posture")
if decl is not None and (not isinstance(decl, str) or not decl.strip()):
errors.append("%s : posture declaree invalide dans pack.json" % k)
continue
posture = {"principale": decl if decl else f["posture"],
"posture_origine": "declaree" if decl else "calculee"}
if decl:
posture["posture_calculee"] = f["posture"]
if f.get("posture_debut") != f.get("posture_fin"):
posture["debut"], posture["fin"] = f.get("posture_debut"), f.get("posture_fin")
# Hauteur d'assise seulement pour une posture assise (une chute traverse des images "assis").
if f.get("hauteur_assise_cm") is not None and posture["principale"] == "assis":
posture["hauteur_assise_cm"] = f["hauteur_assise_cm"]
dur_p = float(f["duree_s"]) or num(pinfo.get("duree_s"))
if dur_p <= 0:
errors.append("%s : duree nulle, cadence de planche incalculable" % k)
continue
animations.append({
"cle": k,
"appel": {"script": "Actor.animate(%s)" % k, "action": "ANIMATION: %s" % k},
"nom": {"fr": d["nom"]["fr"], "en": d["nom"]["en"]},
"assets": {"feminin": e["asset_F"], "masculin": e["asset_M"]},
"duree_s": round(float(f["duree_s"]), 2),
"images": f["images"],
"planche": {"fichier": "planches/%s.jpg" % k, "images": pinfo["images"], "taille": 256,
"ips": round(pinfo["images"] / dur_p, 1)},
"cadence": f["cadence"],
"taille_ko": {"feminin": round(tf / 1024), "masculin": round(tm / 1024), "total": round((tf + tm) / 1024)},
"taille_cuite": False,
"boucle": {"oui": bool(f["boucle"]), "ecart_pct": round(f.get("boucle_ecart_pct") or 0.0, 2)},
"deplacement": {"cm": f["deplacement_cm"], "cm_s": f["vitesse_cm_s"], "sens": f["deplacement_sens"]},
"posture": posture,
"contacts": {"pieds_au_sol_pct": f["pieds_au_sol_pct"], "mains": f["mains"]},
"accessoire": d.get("accessoire", ""),
"categorie": d["categorie"],
"style": d["style"],
"usage": {"fr": d["usage"]["fr"], "en": d["usage"]["en"]},
"corps": ["feminin", "masculin"],
"qualite": {"verdict": "à revoir" if motifs else "accepté", "motifs": motifs,
"pire_brut_pct": round(q["brut"], 2), "pire_torsion_deg": round(q["torsion"], 2)},
"source": pack.get("source", ""),
"licence": pack.get("licence", ""),
"credit": pack.get("credit", ""),
"version": args.version,
"empreinte": sha256_of(files),
})
thumbs.append((k, thumb, pj))
if errors:
return None
header = {
"pack": pack.get("pack", ""), "mod": args.mod, "version": args.version,
"genere": datetime.datetime.now().isoformat(timespec="seconds"),
"nombre_animations": len(animations), "credit": pack.get("credit", ""),
"animations": animations,
}
return header, thumbs, out
# ---------------------------------------------------------------------------- HTML
CSS = """
:root{--bg:#f6f7f9;--fg:#1d2330;--mut:#5d6677;--card:#fff;--bd:#d9dde5;--acc:#2057c9;--ok:#1d7a3f;--warn:#a85b00;--chip:#eef1f6}
@media (prefers-color-scheme:dark){:root{--bg:#14171d;--fg:#e6e9ef;--mut:#9aa3b4;--card:#1d222b;--bd:#323a48;--acc:#7aa7ff;--ok:#5fcf8a;--warn:#f0a850;--chip:#262c37}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.45 system-ui,Segoe UI,sans-serif;overflow-wrap:anywhere}
header{padding:16px;max-width:1200px;margin:0 auto}
h1{margin:0 0 4px;font-size:1.4rem}
.sub{color:var(--mut);font-size:.9rem}
.bar{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
.bar input,.bar select{font:inherit;padding:6px 8px;border:1px solid var(--bd);border-radius:6px;background:var(--card);color:var(--fg);min-width:0;max-width:100%}
.bar input{flex:1 1 220px}
main{max-width:1200px;margin:0 auto;padding:0 16px 24px;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr));gap:12px}
.card{background:var(--card);border:1px solid var(--bd);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
.card[hidden],.card img[hidden],.card .anim[hidden]{display:none}
.card img{width:100%;aspect-ratio:1/1;object-fit:contain;background:var(--chip);display:block}
.card .anim{width:100%;aspect-ratio:1/1;background-color:var(--chip);background-repeat:no-repeat}
.in{padding:10px 12px;display:flex;flex-direction:column;gap:6px}
.in h2{margin:0;font-size:1.05rem}
.key{font-family:Consolas,monospace;background:var(--chip);border:1px solid var(--bd);border-radius:6px;padding:3px 6px;cursor:pointer;text-align:left;color:var(--fg);font-size:.85rem}
.call{font-family:Consolas,monospace;font-size:.8rem;color:var(--mut)}
.tags{display:flex;flex-wrap:wrap;gap:4px}
.tags span{background:var(--chip);border-radius:10px;padding:1px 8px;font-size:.78rem}
.use{font-size:.88rem}
.q-ok{color:var(--ok)}.q-rev{color:var(--warn);font-weight:600}
footer{max-width:1200px;margin:0 auto;padding:0 16px 24px;color:var(--mut);font-size:.8rem}
#none{display:none;padding:16px;max-width:1200px;margin:0 auto}
"""
JS = """
const A=JSON.parse(document.getElementById('data').textContent).animations;
const $=id=>document.getElementById(id);
const cap=s=>s.charAt(0).toUpperCase()+s.slice(1);
function dur(a){return a.duree_s<2?'courte':(a.duree_s>5?'longue':'moyenne')}
function fill(sel,vals){[...new Set(vals)].sort().forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent=v;sel.appendChild(o)})}
fill($('fcat'),A.map(a=>a.categorie));fill($('fpos'),A.map(a=>a.posture.principale));
const SENS={'sur place':'sur place','avant':'avance','arriere':'recule','cote':'de côté'};
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
function card(a){
const dp=a.deplacement,p=a.posture;
let pos=p.principale;if(p.debut)pos+=' ('+p.debut+' → '+p.fin+')';if(p.hauteur_assise_cm!=null)pos+=', assise '+p.hauteur_assise_cm+' cm';
const mv=dp.sens==='sur place'?'sur place':(SENS[dp.sens]||dp.sens)+' '+Math.round(dp.cm)+' cm ('+Math.round(dp.cm_s)+' cm/s)';
const q=a.qualite,qc=q.verdict==='accepté'?'q-ok':'q-rev';
const el=document.createElement('article');el.className='card';
el.innerHTML='<img loading="lazy" src="vignettes/'+esc(a.cle)+'.png" alt="'+esc(a.nom.fr)+'">'
+'<div class="in"><h2>'+esc(a.nom.fr)+' <span class="sub">/ '+esc(a.nom.en)+'</span></h2>'
+'<button class="key" type="button" title="Copier la clé">'+esc(a.cle)+'</button>'
+'<div class="call">'+esc(a.appel.script)+'<br>'+esc(a.appel.action)+'</div>'
+'<div class="tags"><span>'+esc(a.categorie)+'</span><span>'+esc(pos)+'</span><span>'+a.duree_s.toString().replace('.',',')+' s ('+dur(a)+')</span>'
+'<span>'+(a.boucle.oui?'boucle':'une fois')+'</span><span>'+esc(mv)+'</span>'+(a.accessoire?'<span>'+esc(a.accessoire)+'</span>':'')+'</div>'
+'<div class="use">'+esc(a.usage.fr)+'</div>'
+'<div class="'+qc+'">Qualité : '+esc(q.verdict)+(q.motifs.length?' ('+esc(q.motifs.join(' ; '))+')':'')+'</div></div>';
hover(el,a);
const b=el.querySelector('.key');
b.onclick=()=>{const done=()=>{b.textContent='copié';setTimeout(()=>b.textContent=a.cle,900)};
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(a.cle).then(done,()=>sel(b))}else sel(b)};
return el}
const reduce=matchMedia('(prefers-reduced-motion: reduce)');
function hover(el,a){
const pl=a.planche,img=el.querySelector('img');let div=null,timer=null,ready=false,want=false,last='mouse';
function show(){if(!want||!ready)return;img.hidden=true;div.hidden=false;let i=0;div.style.backgroundPosition='0% 0%';
clearInterval(timer);timer=setInterval(()=>{i=(i+1)%pl.images;div.style.backgroundPosition=(i*100/(pl.images-1))+'% 0%'},1000/pl.ips)}
function start(){want=true;
if(!div){div=document.createElement('div');div.className='anim';div.hidden=true;div.setAttribute('role','img');div.setAttribute('aria-label',a.nom.fr);
div.style.backgroundSize=(pl.images*100)+'% 100%';img.parentNode.insertBefore(div,img);
const im=new Image();im.onload=()=>{div.style.backgroundImage='url("'+pl.fichier+'")';ready=true;show()};im.src=pl.fichier}
else show()}
function stop(){want=false;clearInterval(timer);timer=null;if(div)div.hidden=true;img.hidden=false}
el.addEventListener('pointerenter',e=>{if(e.pointerType!=='touch'&&!reduce.matches)start()});
el.addEventListener('pointerleave',e=>{if(e.pointerType!=='touch')stop()});
el.addEventListener('pointerdown',e=>{last=e.pointerType});
el.addEventListener('click',e=>{if(!e.target.closest('img,.anim'))return;if(last==='touch'||reduce.matches){want?stop():start()}})}
function sel(b){const r=document.createRange();r.selectNodeContents(b);const s=getSelection();s.removeAllRanges();s.addRange(r)}
const main=$('list'),cards=A.map(a=>({a,el:card(a)}));cards.forEach(c=>main.appendChild(c.el));
function apply(){
const t=$('q').value.trim().toLowerCase(),c=$('fcat').value,p=$('fpos').value,d=$('fdur').value;let n=0;
cards.forEach(x=>{const a=x.a;
const hay=(a.cle+' '+a.nom.fr+' '+a.nom.en+' '+a.usage.fr+' '+a.usage.en+' '+a.accessoire+' '+a.style).toLowerCase();
const ok=(!t||hay.includes(t))&&(!c||a.categorie===c)&&(!p||a.posture.principale===p)&&(!d||dur(a)===d);
x.el.hidden=!ok;if(ok)n++});
$('count').textContent=n+' / '+A.length+' animation(s)';$('none').style.display=n?'none':'block'}
['q','fcat','fpos','fdur'].forEach(i=>$(i).addEventListener('input',apply));apply();
"""
def render_html(cat):
data = json.dumps(cat, ensure_ascii=False).replace("</", "<\\/")
title = "Animations — %s" % cat["mod"]
return """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>%(title)s</title>
<style>%(css)s</style>
</head>
<body>
<header>
<h1>%(title)s</h1>
<div class="sub">Version %(version)s — %(n)d animations — généré le %(date)s. Copiez la clé puis écrivez <code>Actor.animate(clé)</code> dans une scène ou <code>ANIMATION: clé</code> dans une action. Survolez une vignette pour voir le mouvement.</div>
<div class="bar">
<input id="q" type="search" placeholder="Rechercher (nom, clé, usage…)" aria-label="Recherche">
<select id="fcat" aria-label="Catégorie"><option value="">Toutes catégories</option></select>
<select id="fpos" aria-label="Posture"><option value="">Toutes postures</option></select>
<select id="fdur" aria-label="Durée"><option value="">Toutes durées</option><option value="courte">Courte (&lt; 2 s)</option><option value="moyenne">Moyenne (2 à 5 s)</option><option value="longue">Longue (&gt; 5 s)</option></select>
</div>
<div id="count" class="sub" style="margin-top:8px"></div>
</header>
<div id="none">Aucune animation ne correspond aux filtres.</div>
<main id="list"></main>
<footer>%(credit)s</footer>
<script id="data" type="application/json">%(data)s</script>
<script>%(js)s</script>
</body>
</html>
""" % {"title": html.escape(title), "css": CSS, "js": JS, "data": data, "version": html.escape(cat["version"]),
"n": cat["nombre_animations"], "date": html.escape(cat["genere"][:10]), "credit": html.escape(cat["credit"])}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--pack", required=True)
ap.add_argument("--pipeline", required=True)
ap.add_argument("--mod", required=True)
ap.add_argument("--version", required=True)
ap.add_argument("--out", required=True)
here = os.path.dirname(os.path.abspath(__file__))
ap.add_argument("--root", default=os.path.dirname(os.path.dirname(here)), help="racine du projet")
ap.add_argument("--mod-dir", default="", help="dossier du mod (defaut : <root>/ModSources/<mod>)")
ap.add_argument("--planches", default="", help="dossier des planches <Clip>.jpg/.json (defaut : <root>/Saved/Measurement/planches)")
ap.add_argument("--vignettes", default="", help="dossier des vignettes <Clip>.png (defaut : <root>/Saved/Measurement/vignettes)")
args = ap.parse_args()
errors = []
result = None
try:
result = build(args, errors)
except Exception as e: # noqa: BLE001
errors.append("erreur inattendue : %r" % (e,))
rapport = os.path.join(os.path.abspath(args.pipeline), "catalogue_rapport.json")
info = {"date": datetime.datetime.now().isoformat(timespec="seconds"), "mod": args.mod, "version": args.version,
"ok": result is not None and not errors, "fiches": len(result[0]["animations"]) if result else 0,
"echecs": errors}
if result is None or errors:
try:
os.makedirs(os.path.dirname(rapport), exist_ok=True)
with open(rapport, "w", encoding="utf-8", newline="\n") as f:
json.dump(info, f, ensure_ascii=False, indent=2)
except OSError:
pass
print("CATALOGUE ECHEC : %d cause(s)" % len(errors), file=sys.stderr)
for e in errors:
print(" - " + e, file=sys.stderr)
return 1
cat, thumbs, out = result
os.makedirs(os.path.join(out, "vignettes"), exist_ok=True)
os.makedirs(os.path.join(out, "planches"), exist_ok=True)
for k, src, psrc in thumbs:
shutil.copyfile(src, os.path.join(out, "vignettes", k + ".png"))
shutil.copyfile(psrc, os.path.join(out, "planches", k + ".jpg"))
with open(os.path.join(out, "catalogue.json"), "w", encoding="utf-8", newline="\n") as f:
json.dump(cat, f, ensure_ascii=False, indent=2)
f.write("\n")
with open(os.path.join(out, "catalogue.html"), "w", encoding="utf-8", newline="\n") as f:
f.write(render_html(cat))
with open(rapport, "w", encoding="utf-8", newline="\n") as f:
json.dump(info, f, ensure_ascii=False, indent=2)
print("CATALOGUE OK : %d fiche(s) -> %s" % (len(cat["animations"]), out))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,214 +0,0 @@
<#
.SYNOPSIS
Copie les clips d'animation convertis dans un mod d'assets, renommes a la convention du jeu (lot AP5).
.DESCRIPTION
/Game/Quaternius/UAL/B_<Clip> -> /<Nom>/Anims/Female/qua_<clip>_F ; M_<Clip> -> /<Nom>/Anims/Male/qua_<clip>_M.
Le jeu appelle Actor.animate(<cle>) et cherche <cle>_F et <cle>_M (cle = ce qui precede le dernier _).
1. controle bloquant : rapport.json existe et valide = vrai (les a_revoir sont affiches, non bloquants) ;
2. controles : editeur ferme, aucun autre plugin dans Mods\ ;
3. table cle / sources / destinations calculee depuis le dossier disque de -Source (/Game/X/Y -> Content\X\Y), B_*.uasset et M_*.uasset ;
4. -DryRun : affiche la table et s'arrete (rien n'est lance ni cree) ;
5. sinon : cree ModSources\<Nom> s'il manque (jamais d'ecrasement), monte le mod, lance Tools\anim\DeliverAnims.py
dans UnrealEditor-Cmd (journal Saved\Logs\portage\AP5_DeliverAnims_<horodatage>.log), remet le mod en place,
controle la sortie (LIVRAISON copies 2n erreurs 0, n fichiers .uasset par sexe). Code de sortie 1 au moindre ecart.
-Catalogue : apres une livraison reussie, lance Tools\anim\Catalogue.py (lot AP6).
-CatalogueOnly : saute la copie et ne produit que le catalogue (rapport valide exige), sans lancer Unreal.
Parametres (defaut = pack Quaternius) : -Pipeline (dossier des rapports, defaut de -Report, lieu de livraison.json ; sous Saved\),
-Pack (pack.json ; donne aussi, quand il est passe explicitement, les textes du mod cree : source, auteur, licence, credit, url ;
refus si licence, credit ou auteur est vide ou "A COMPLETER" ; sans -Pack, textes Quaternius d'origine), -Vignettes / -Planches (dossiers transmis a Catalogue.py), -Source (clips convertis B_/M_), -Name, -Prefix, -Exclude.
.EXAMPLE
.\Tools\anim\DeliverAnims.ps1 -DryRun
.\Tools\anim\DeliverAnims.ps1 -DryRun -Pipeline Saved\AnimPipeline\freeanims -Source /Game/AnimPacks/FreeAnimationsPack -Name lpr_anim_freeanims -Prefix fap -Exclude ''
.\Tools\anim\DeliverAnims.ps1
#>
param(
[string] $Name = 'lpr_anim_quaternius',
[string] $Source = '/Game/Quaternius/UAL',
[string] $Prefix = 'qua',
[string] $Exclude = 'A_TPose',
[string] $Report = '',
[string] $Pipeline = 'Saved\AnimPipeline\quaternius',
[string] $Pack = 'AnimSources\quaternius\pack.json',
[string] $Vignettes = '',
[string] $Planches = '',
[switch] $DryRun,
[switch] $Catalogue,
[switch] $CatalogueOnly
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $Root 'Tools\ModSource.ps1')
$Editor = 'D:\Game\Launcher\Engine\UE_5.5\Engine\Binaries\Win64\UnrealEditor-Cmd.exe'
# -Pipeline : dossier des rapports du pack (relatif a la racine du projet, ou absolu) ; defaut de -Report, lieu de livraison.json.
# -Report seul (sans -Pipeline) : le dossier du rapport fait office de dossier de pipeline (comportement d'avant).
if ($Report -and -not $PSBoundParameters.ContainsKey('Pipeline')) { $Pipeline = Split-Path $Report -Parent }
if (-not [IO.Path]::IsPathRooted($Pipeline)) { $Pipeline = Join-Path $Root $Pipeline }
$Pipeline = $Pipeline.TrimEnd('\', '/')
if (-not $Report) { $Report = Join-Path $Pipeline 'rapport.json' }
if (-not [IO.Path]::IsPathRooted($Pack)) { $Pack = Join-Path $Root $Pack }
function New-IfMissing([string] $Path, [string] $Text) {
if (Test-Path $Path) { return }
New-Item -ItemType Directory -Force (Split-Path $Path -Parent) | Out-Null
[IO.File]::WriteAllText($Path, $Text, (New-Object Text.UTF8Encoding $false))
Write-Host " cree : $Path"
}
# Lot AP6 : catalogue du mod (Tools\anim\Catalogue.py), version lue dans le .lpmod.
function Invoke-Catalogue {
$lpmodPath = Join-Path $Root "ModSources\$Name\Module\$Name.lpmod"
if (-not (Test-Path $lpmodPath)) { throw "lpmod introuvable : $lpmodPath" }
$line = Select-String -Path $lpmodPath -Pattern '^MODULE_VERSION:\s*(\S+)' | Select-Object -First 1
if (-not $line) { throw "MODULE_VERSION absente de $lpmodPath" }
$version = $line.Matches[0].Groups[1].Value
$out = Join-Path $Root "ModSources\$Name\Module\Animations"
Write-Host "Catalogue : version $version -> $out"
$extra = @()
if ($Vignettes) { $extra += @('--vignettes', $Vignettes) }
if ($Planches) { $extra += @('--planches', $Planches) }
& python (Join-Path $Root 'Tools\anim\Catalogue.py') --pack $Pack `
--pipeline $Pipeline --mod $Name --version $version --out $out @extra
if ($LASTEXITCODE -ne 0) { throw "Catalogue.py a echoue (code $LASTEXITCODE)" }
}
try {
# 1. Verdict des controles qualite.
if (-not (Test-Path $Report)) { throw "rapport.json introuvable : $Report" }
$rapport = Get-Content $Report -Raw -Encoding utf8 | ConvertFrom-Json
if ($rapport.valide -ne $true) {
$echecs = @($rapport.echecs) -join '; '
throw "Rapport non valide ($Report). Echecs : $echecs"
}
foreach ($r in @($rapport.a_revoir)) { Write-Warning "a revoir : $r" }
if ($CatalogueOnly -and -not $DryRun) {
Invoke-Catalogue
exit 0
}
# 2. Controles d'environnement.
$running = @(Get-Process UnrealEditor, UnrealEditor-Cmd -ErrorAction SilentlyContinue)
if ($running) { throw "UnrealEditor est ouvert (pid $($running[0].Id)) : le fermer avant la livraison" }
$stray = Get-StrayMods $Root
if ($stray) { throw "Autres plugins dans Mods\ ($($stray -join ', ')) : les remettre dans ModSources" }
# 3. Table des cles.
if ($Source -notmatch '^/Game/(.+)$') { throw "-Source doit commencer par /Game/ (recu : $Source)" }
$srcDir = Join-Path $Root ('Content\' + ($Matches[1] -replace '/', '\'))
if (-not (Test-Path $srcDir)) { throw "Dossier source introuvable : $srcDir" }
$excluded = @($Exclude -split ',' | Where-Object { $_ })
$fem = @{}; $mal = @{}
foreach ($f in Get-ChildItem $srcDir -File -Filter '*.uasset') {
$base = $f.BaseName
if ($base -like 'B_*') { $fem[$base.Substring(2)] = $Source.TrimEnd('/') + '/' + $base }
elseif ($base -like 'M_*') { $mal[$base.Substring(2)] = $Source.TrimEnd('/') + '/' + $base }
}
$clips = @(($fem.Keys + $mal.Keys) | Sort-Object -Unique | Where-Object { $_ -notin $excluded })
$table = @()
$seen = @{}
foreach ($clip in $clips) {
if (-not $fem.ContainsKey($clip)) { throw "Clip $clip : source B_ absente" }
if (-not $mal.ContainsKey($clip)) { throw "Clip $clip : source M_ absente" }
$cle = ($Prefix + '_' + $clip).ToLowerInvariant()
if ($cle -notmatch '^[a-z0-9_]+$') { throw "Cle invalide (caracteres hors [a-z0-9_]) : $cle" }
if ($seen.ContainsKey($cle)) { throw "Cle en double : $cle ($clip et $($seen[$cle]))" }
$seen[$cle] = $clip
$table += [pscustomobject]@{
Cle = $cle; SourceF = $fem[$clip]; SourceM = $mal[$clip]
AssetF = "/$Name/Anims/Female/${cle}_F"; AssetM = "/$Name/Anims/Male/${cle}_M"
}
}
if ($table.Count -eq 0) { throw "Aucun clip a livrer dans $srcDir" }
Write-Host "Table : $($table.Count) cle(s) pour /$Name/ (exclus : $($excluded -join ', '))"
$table | Select-Object -First 5 | ForEach-Object { Write-Host (" {0} : {1} -> {2} ; {3} -> {4}" -f $_.Cle, $_.SourceF, $_.AssetF, $_.SourceM, $_.AssetM) }
if ($DryRun) {
Write-Host "A blanc : rien lance, rien cree"
exit 0
}
# 5. Sources du mod (jamais d'ecrasement). Textes : pack.json si -Pack est donne, sinon ceux de Quaternius.
$paths = Get-ModSourcePaths $Root $Name
$src = $paths.Source
$title = 'Quaternius Animations'
$desc = 'Free animation clips from the Quaternius Universal Animation Library (CC0), retargeted for LifePlay'
$author = 'LifePlay Revival (animations by Quaternius)'
$licence = 'CC0'
$link = 'https://quaternius.com'
$credits = "Credits and licenses of the assets in Quaternius Animations (one line per asset: title, author, license, source URL, changes).`r`n" +
"- Universal Animation Library by Quaternius, CC0 1.0, https://quaternius.com . Changes: retargeted to the Genesis 8 skeletons (Bridget, Michael), renamed qua_<clip>_F / qua_<clip>_M.`r`n"
if ($PSBoundParameters.ContainsKey('Pack')) {
$pk = Get-Content $Pack -Raw -Encoding utf8 | ConvertFrom-Json
foreach ($k in 'source', 'auteur', 'licence', 'credit') {
if (-not ([string] $pk.$k).Trim() -or ([string] $pk.$k) -match 'A COMPLETER') { throw "pack.json : '$k' vide ou 'A COMPLETER' (mod non cree)" }
}
$title = [string] $pk.source
$desc = "Free animation clips from $($pk.source) ($($pk.licence)), retargeted for LifePlay"
$author = "LifePlay Revival (animations by $($pk.auteur))"
$licence = [string] $pk.licence
# Lien de l'auteur : url_auteur (page de l'auteur) s'il est donne, sinon url (page du pack).
$authorUrl = if ($pk.PSObject.Properties['url_auteur']) { [string] $pk.url_auteur } else { [string] $pk.url }
$link = if ($authorUrl.Trim() -and $authorUrl -notmatch 'A COMPLETER') { $authorUrl } else { '' }
$credits = "Credits and licenses of the assets in $title (one line per asset: title, author, license, source URL, changes).`r`n" +
"- $($pk.credit)$(if ($pk.url -and ([string] $pk.url) -notmatch 'A COMPLETER') { ', ' + $pk.url }) . Changes: retargeted to the Genesis 8 skeletons (Bridget, Michael), renamed $($pk.prefixe)_<clip>_F / $($pk.prefixe)_<clip>_M.`r`n"
}
$uplugin = [ordered]@{
FileVersion = 3; Version = 1; VersionName = '1.0'; FriendlyName = $title
Description = $desc; Category = 'LifePlay Mods'; CreatedBy = $author
CanContainContent = $true; IsBetaVersion = $false; Installed = $false
}
if ($link) { $uplugin.CreatedByURL = $link }
New-IfMissing (Join-Path $src "$Name.uplugin") (($uplugin | ConvertTo-Json) + "`r`n")
$lpmod = @(
"MODULE_UNIQUEID: $Name", "MODULE_NAME: $title", "MODULE_AUTHOR: $author",
'MODULE_LINK: https://github.com/edujiro5972/LifePlay-Revival', "MODULE_DESCRIPTION: $desc",
'MODULE_REQUIREMENTS: vin_Base', 'MODULE_VERSION: 1.0', "MODULE_CREATED: $(Get-Date -Format 'yyyy-MM-dd')",
"MODULE_LICENSE: $licence") + @($(if ($link) { "MODULE_AUTHOR_LINK: $link" })) + @('MODULE_PRICE: Free', 'MODULE_DEFAULT_ENABLED: true')
New-IfMissing (Join-Path $src "Module\$Name.lpmod") (($lpmod -join "`r`n") + "`r`n")
New-IfMissing (Join-Path $src 'Module\CREDITS.txt') $credits
New-Item -ItemType Directory -Force (Join-Path $src 'Content') | Out-Null
# Lancement d'Unreal, mod monte.
$logDir = Join-Path $Root 'Saved\Logs\portage'
New-Item -ItemType Directory -Force $logDir | Out-Null
$log = Join-Path $logDir ('AP5_DeliverAnims_{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
$py = (Join-Path $Root 'Tools\anim\DeliverAnims.py') -replace '\\', '/'
$mounted = Mount-ModSource $Root $Name
try {
# Le script Python decoupe ses arguments sur les espaces : pipeline= est donne relatif a Saved\ (sans espace).
$savedDir = (Join-Path $Root 'Saved').TrimEnd('\') + '\'
if (-not $Pipeline.StartsWith($savedDir, [StringComparison]::OrdinalIgnoreCase)) { throw "-Pipeline doit etre sous $savedDir (recu : $Pipeline)" }
$pipeRel = $Pipeline.Substring($savedDir.Length) -replace '\\', '/'
if ($pipeRel -match '\s') { throw "-Pipeline ne doit pas contenir d'espace sous Saved\ (recu : $pipeRel)" }
$script = "-script=$py mod=$Name src=$Source prefix=$Prefix exclude=$Exclude pipeline=$pipeRel"
Write-Host "Livraison dans /$Name/ (journal : $log)"
& $Editor (Join-Path $Root 'LifePlay.uproject') -run=pythonscript $script -unattended -nopause -nosplash -stdout -FullStdOutLogOutput *> $log
}
finally {
Dismount-ModSource $Root $Name
}
# Controle de sortie.
$n = $table.Count
if (-not (Select-String -Path $log -Pattern "LIVRAISON copies $($n * 2) erreurs 0\b" -Quiet)) {
$last = Select-String -Path $log -Pattern 'LIVRAISON' | Select-Object -Last 3 | ForEach-Object { $_.Line }
throw "Ligne 'LIVRAISON copies $($n * 2) erreurs 0' absente : $($last -join ' | ') (voir $log)"
}
foreach ($sexe in 'Female', 'Male') {
$dir = Join-Path $src "Content\Anims\$sexe"
$count = @(Get-ChildItem $dir -Filter '*.uasset' -ErrorAction SilentlyContinue).Count
if ($count -ne $n) { throw "$dir : $count fichier(s) .uasset pour $n cle(s)" }
}
Write-Host "Livraison terminee : $n cle(s), $($n * 2) copies"
if ($Catalogue) { Invoke-Catalogue }
exit 0
}
catch {
Write-Host "ECHEC : $($_.Exception.Message)" -ForegroundColor Red
exit 1
}

View file

@ -1,136 +0,0 @@
# Livraison des clips d'animation convertis dans un mod d'assets (lot AP5).
# Lancement :
# UnrealEditor-Cmd.exe <projet> -run=pythonscript "-script=<chemin>/Tools/anim/DeliverAnims.py mod=<Nom> src=/Game/Quaternius/UAL prefix=qua exclude=A_TPose pipeline=AnimPipeline/quaternius"
# Copie <src>/B_<Clip> -> /<mod>/Anims/Female/<prefix>_<clip>_F
# <src>/M_<Clip> -> /<mod>/Anims/Male/<prefix>_<clip>_M (src = dossier des clips convertis B_/M_)
# Les sources ne sont jamais deplacees ni renommees. Seules des copies du mod peuvent etre supprimees.
import json
import os
import re
import sys
import unreal
# Arguments : mod=... src=... prefix=... exclude=A,B (le script peut recevoir les arguments en un seul bloc)
# pipeline= : dossier de livraison.json, relatif a Saved/ (ou absolu) ; sans espace (arguments decoupes sur les espaces).
args = {"mod": "lpr_anim_quaternius", "src": "/Game/Quaternius/UAL", "prefix": "qua", "exclude": "A_TPose",
"pipeline": "AnimPipeline/quaternius"}
tokens = []
for a in sys.argv[1:]:
tokens.extend(a.split())
for t in tokens:
if "=" in t:
k, v = t.split("=", 1)
args[k.strip().lower()] = v.strip()
MOD = args["mod"]
SRC = args["src"].rstrip("/")
PREFIX = args["prefix"]
EXCLUDE = set(x for x in args["exclude"].split(",") if x)
erreurs = []
livraison = []
def erreur(msg):
erreurs.append(msg)
unreal.log_error("LIVRAISON erreur : " + msg)
def lister(dossier, debut):
noms = set()
for p in unreal.EditorAssetLibrary.list_assets(dossier, recursive=False, include_folder=False):
nom = p.split("/")[-1].split(".")[0]
if nom.startswith(debut):
noms.add(nom[len(debut):])
return noms
def infos(asset):
return asset.get_skeleton(), asset.get_editor_property("number_of_sampled_keys"), asset.get_editor_property("sequence_length")
# Garde-fou : on ne supprime et n'ecrit que dans le mod.
def sous_mod(chemin):
return chemin.startswith("/" + MOD + "/")
# En commandlet, le registre n'a pas encore explore le dossier du mod : sans ce scan, les copies d'une passe
# precedente sont ignorees par does_asset_exist et duplicate_asset echoue (AP5, deuxieme passe).
unreal.AssetRegistryHelpers.get_asset_registry().scan_paths_synchronous(["/" + MOD], True)
clips = sorted((lister(SRC, "B_") & lister(SRC, "M_")) - EXCLUDE)
unreal.log("LIVRAISON %d clip(s) a livrer vers /%s/" % (len(clips), MOD))
for clip in clips:
cle = PREFIX + "_" + clip.lower()
if not re.match(r"^[a-z0-9_]+$", cle):
erreur("cle invalide : " + cle)
continue
sources = {"F": SRC + "/B_" + clip, "M": SRC + "/M_" + clip}
dests = {"F": "/%s/Anims/Female/%s_F" % (MOD, cle), "M": "/%s/Anims/Male/%s_M" % (MOD, cle)}
ref = {}
ok = True
for sexe in ("F", "M"):
s, d = sources[sexe], dests[sexe]
if not sous_mod(d):
erreur("destination hors du mod : " + d)
ok = False
continue
asrc = unreal.EditorAssetLibrary.load_asset(s)
if not isinstance(asrc, unreal.AnimSequence):
erreur("source absente ou pas une AnimSequence : " + s)
ok = False
continue
if unreal.EditorAssetLibrary.does_asset_exist(d):
if not unreal.EditorAssetLibrary.delete_asset(d):
erreur("suppression de la copie precedente impossible : " + d)
ok = False
continue
copie = unreal.EditorAssetLibrary.duplicate_asset(s, d)
if copie is None:
erreur("copie impossible : %s -> %s" % (s, d))
ok = False
continue
# Un mod DLC ne peut pas referencer du contenu moteur non cuit : les clips d'un pack UE5 pointent
# explicitement sur /Engine/Animation/DefaultAnim*CompressionSettings (cuisson refusee, AP7). Sans
# reglage (None), la compression par defaut du projet s'applique, comme pour les clips Quaternius.
for prop in ("bone_compression_settings", "curve_compression_settings"):
valeur = copie.get_editor_property(prop)
if valeur is not None and valeur.get_path_name().startswith("/Engine/"):
copie.set_editor_property(prop, None)
if not unreal.EditorAssetLibrary.save_asset(d, only_if_is_dirty=False):
erreur("enregistrement impossible : " + d)
ok = False
continue
# Controles : existe, AnimSequence, meme squelette, meme nombre d'images, meme duree.
acopie = unreal.EditorAssetLibrary.load_asset(d)
if not unreal.EditorAssetLibrary.does_asset_exist(d) or not isinstance(acopie, unreal.AnimSequence):
erreur("copie absente ou pas une AnimSequence : " + d)
ok = False
continue
sk0, n0, l0 = infos(asrc)
sk1, n1, l1 = infos(acopie)
if sk0 != sk1:
erreur("squelette different : " + d)
ok = False
if n0 != n1:
erreur("nombre d'images different (%s / %s) : %s" % (n0, n1, d))
ok = False
if abs(l0 - l1) > 1e-4:
erreur("duree differente (%s / %s) : %s" % (l0, l1, d))
ok = False
ref[sexe] = (n1, l1)
if ok:
livraison.append({
"cle": cle,
"source_F": sources["F"], "source_M": sources["M"],
"asset_F": dests["F"], "asset_M": dests["M"],
"images": ref["F"][0], "duree_s": ref["F"][1],
})
sortie = os.path.join(unreal.Paths.project_saved_dir(), args["pipeline"], "livraison.json") # join : absolu prime
os.makedirs(os.path.dirname(sortie), exist_ok=True)
with open(sortie, "w", encoding="utf-8") as f:
json.dump(livraison, f, indent=2, ensure_ascii=False)
unreal.log("LIVRAISON copies %d erreurs %d" % (len(livraison) * 2, len(erreurs)))

View file

@ -1,359 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Genere un rapport HTML statique a partir des sorties du commandlet
d'evaluation d'animations (rapport.json, rapport_poseT_*.json, eval_*.csv,
controles_*.csv).
Usage :
python RapportControles.py [dossier_resultats]
Par defaut : D:\\Game\\LifePlay-master 5.5\\Saved\\AnimPipeline\\quaternius
Sortie : <dossier>/rapport.html (une seule page, CSS integre, hors ligne).
Bibliotheque standard uniquement.
"""
import csv
import datetime
import html
import json
import os
import sys
# Defaut = dossier du pack Quaternius ; tout autre pack : passer son dossier en argument.
DOSSIER_DEFAUT = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"Saved", "AnimPipeline", "quaternius")
# (cle CSV valeur, cle CSV os, titre, unite, seuil)
MESURES = [
("sol_cm", "sol_os", "Sol", "cm", 3.0),
("glissement_cm", "glissement_os", "Glissement", "cm/image", 3.0),
("genou_cm", "genou_os", "Genou", "cm", 1.0),
("flexion_deg", "flexion_os", "Flexion", "\u00b0", 5.0),
("saut_deg", "saut_os", "Saut", "\u00b0", 10.0),
("penetration_cm", "penetration_os", "Penetration", "cm", 1.0),
]
FACTEUR_REJET = 3.0
ORDRE_VERDICT = {"rejete": 0, "a revoir": 1, "accepte": 2}
def esc(texte):
return html.escape(str(texte), quote=True)
def lire_json(chemin):
# utf-8-sig : tolere un eventuel BOM
with open(chemin, "r", encoding="utf-8-sig") as f:
return json.load(f)
def lire_csv(chemin):
with open(chemin, "r", encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
def nombre(valeur, defaut=0.0):
try:
return float(valeur)
except (TypeError, ValueError):
return defaut
def fmt(valeur, decimales=2):
"""Formate un nombre ; notation scientifique pour les tres petites valeurs."""
try:
x = float(valeur)
except (TypeError, ValueError):
return "-"
if x != 0 and abs(x) < 0.005:
return "%.1e" % x
return ("%." + str(decimales) + "f") % x
def classe_mesure(valeur, seuil):
if valeur > seuil * FACTEUR_REJET:
return "m-rejet"
if valeur > seuil:
return "m-revoir"
return ""
# ---------------------------------------------------------------- sections
def html_entete(rapport, date_txt):
valide = bool(rapport.get("valide"))
cls = "ok" if valide else "ko"
texte = "VALIDE" if valide else "REFUS\u00c9"
return (
'<header>\n'
'<h1>Rapport des contr\u00f4les d\'animations</h1>\n'
'<div class="verdict %s" role="status">%s</div>\n'
'<dl class="meta">\n'
'<div><dt>G\u00e9n\u00e9r\u00e9 le</dt><dd>%s</dd></div>\n'
'<div><dt>Dossier des clips</dt><dd><code>%s</code></dd></div>\n'
'<div><dt>Clips</dt><dd>%s</dd></div>\n'
'<div><dt>Instants par clip</dt><dd>%s</dd></div>\n'
'</dl>\n</header>\n'
) % (cls, texte, esc(date_txt), esc(rapport.get("dossier_clips", "?")),
esc(rapport.get("clips", "?")), esc(rapport.get("instants", "?")))
def html_echecs_revoir(rapport):
echecs = rapport.get("echecs") or []
revoir = rapport.get("a_revoir") or []
sortie = ['<section>\n<h2>\u00c9checs</h2>\n']
if echecs:
sortie.append('<ul class="liste ko-liste">\n')
sortie += ["<li>%s</li>\n" % esc(e) for e in echecs]
sortie.append("</ul>\n")
else:
sortie.append('<p class="vide">Aucun \u00e9chec.</p>\n')
sortie.append("</section>\n<section>\n<h2>Clips \u00e0 revoir</h2>\n")
if revoir:
sortie.append('<ul class="liste">\n')
sortie += ["<li>%s</li>\n" % esc(e) for e in revoir]
sortie.append("</ul>\n")
else:
sortie.append('<p class="vide">Aucun clip \u00e0 revoir.</p>\n')
sortie.append("</section>\n")
return "".join(sortie)
def html_legende():
items = []
for _, _, titre, unite, seuil in MESURES:
items.append("<li><b>%s</b> %s %s</li>" % (
esc(titre), esc(fmt(seuil, 0)), esc(unite)))
return (
'<section class="legende">\n<h2>L\u00e9gende des seuils</h2>\n'
'<ul class="seuils">%s</ul>\n'
'<p>Une mesure au-del\u00e0 du seuil donne <span class="puce revoir">'
'\u00e0 revoir</span> ; au-del\u00e0 de %s fois le seuil, '
'<span class="puce rejete">rejet\u00e9</span>. '
'Dans chaque cellule : la valeur, puis l\'os concern\u00e9 en petit.</p>\n'
'</section>\n'
) % ("".join(items), esc(fmt(FACTEUR_REJET, 0)))
def html_resume_corps(c):
cartes = [
("Formule pire", fmt(c.get("formule_pire"), 3) + " %"),
("Clip pire", fmt(c.get("clip_pire"), 3) + " %"),
("Torsion pire", fmt(c.get("torsion_pire_deg"), 3) + " \u00b0"),
("Clips \u00e0 revoir", str(c.get("clips_a_revoir_controles", "?"))),
("Instants mesur\u00e9s", str(c.get("instants_mesures", "?"))),
("Instants hors seuil", str(c.get("instants_hors_seuil", "?"))),
]
return '<div class="cartes">%s</div>\n' % "".join(
'<div class="carte"><span class="k">%s</span><span class="v">%s</span></div>'
% (esc(k), esc(v)) for k, v in cartes)
def html_controles(lignes):
lignes = sorted(
lignes, key=lambda r: (ORDRE_VERDICT.get(r.get("verdict", ""), 3),
r.get("clip", "")))
tete = "<th>Clip</th><th>Images</th>" + "".join(
"<th>%s <small>(%s)</small></th>" % (esc(t), esc(u))
for _, _, t, u, _ in MESURES) + "<th>Verdict</th>"
corps = []
for r in lignes:
verdict = r.get("verdict", "")
cls = {"rejete": "v-rejete", "a revoir": "v-revoir"}.get(verdict, "v-accepte")
cases = ['<td class="clip">%s</td>' % esc(r.get("clip", "")),
'<td class="num">%s</td>' % esc(r.get("images", ""))]
for cle_val, cle_os, _, _, seuil in MESURES:
v = nombre(r.get(cle_val))
os_ = r.get(cle_os, "")
cases.append(
'<td class="num %s">%s%s</td>' % (
classe_mesure(v, seuil), esc(fmt(v)),
('<small class="os">%s</small>' % esc(os_)) if os_ else ""))
cases.append('<td><span class="puce %s">%s</span></td>' % (
esc(verdict.replace(" ", "")), esc(verdict)))
corps.append('<tr class="%s">%s</tr>' % (cls, "".join(cases)))
return ('<div class="defile"><table class="controles">\n<thead><tr>%s</tr></thead>\n'
'<tbody>\n%s\n</tbody></table></div>\n') % (tete, "\n".join(corps))
def html_pose(pose):
if not pose:
return '<p class="vide">Pas de rapport de pose T pour ce corps.</p>\n'
seg = pose.get("segments") or []
lignes = []
for s in seg:
cls = "ajuste" if s.get("ajuste") else ""
lignes.append(
'<tr class="%s"><td>%s</td><td class="num">%s</td><td class="num">%s</td>'
'<td>%s</td></tr>' % (
cls, esc(s.get("segment", "")), esc(fmt(s.get("avant"))),
esc(fmt(s.get("apres"))), "ajust\u00e9" if s.get("ajuste") else ""))
reglages = pose.get("reglages") or []
lig_reg = ['<tr><td>%s</td><td class="num">%s</td></tr>' % (
esc(r.get("os", "")), esc(fmt(r.get("degres")))) for r in reglages]
valide = pose.get("valide")
return (
'<p>Profil : <b>%s</b> \u00b7 sym\u00e9trie max : <b>%s</b> \u00b7 pose T : '
'<span class="puce %s">%s</span></p>\n'
'<div class="deux">\n'
'<div><h4>Segments (\u00e9cart en %% de 180\u00b0, avant / apr\u00e8s ajustement)</h4>'
'<div class="defile"><table><thead><tr><th>Segment</th><th>Avant</th>'
'<th>Apr\u00e8s</th><th></th></tr></thead><tbody>%s</tbody></table></div></div>\n'
'<div><h4>R\u00e9glages appliqu\u00e9s (degr\u00e9s)</h4>'
'<div class="defile"><table><thead><tr><th>Os</th><th>Degr\u00e9s</th></tr>'
'</thead><tbody>%s</tbody></table></div></div>\n</div>\n'
) % (esc(pose.get("profil", "?")), esc(fmt(pose.get("ecart_symetrie_max"), 4)),
"accepte" if valide else "rejete", "valide" if valide else "refus\u00e9",
"".join(lignes), "".join(lig_reg) or
'<tr><td colspan="2">Aucun r\u00e9glage.</td></tr>')
def html_ecarts(lignes):
tete = ("<th>Clip</th><th>Instant (s)</th><th>Formule (%)</th><th>Clip brut (%)</th>"
"<th>Torsion (\u00b0)</th>")
corps = []
for r in lignes:
corps.append(
'<tr><td class="clip">%s</td><td class="num">%s</td>'
'<td class="num">%s<small class="os">%s</small></td>'
'<td class="num">%s<small class="os">%s</small></td>'
'<td class="num">%s<small class="os">%s</small></td></tr>' % (
esc(r.get("clip", "")), esc(r.get("instant", "")),
esc(fmt(r.get("formule_pire"))), esc(r.get("formule_os", "")),
esc(fmt(r.get("brut_pire"))), esc(r.get("brut_os", "")),
esc(fmt(r.get("torsion_deg"))), esc(r.get("torsion_os", ""))))
return (
'<details><summary>\u00c9carts formule / clip / torsion par clip et par '
'instant (%d lignes)</summary>\n<div class="defile"><table class="ecarts">'
'<thead><tr>%s</tr></thead><tbody>\n%s\n</tbody></table></div></details>\n'
) % (len(lignes), tete, "\n".join(corps))
CSS = """
:root{--fond:#f6f7f9;--carte:#fff;--texte:#1b1f24;--doux:#5b6470;--bord:#d7dce2;
--ok:#1a7f37;--ok-fond:#dafbe1;--ko:#cf222e;--ko-fond:#ffebe9;
--revoir:#9a6700;--revoir-fond:#fff3c4;--rejet-fond:#ffd8d3;--entete:#eef0f3}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--fond:#0f1317;
--carte:#171c22;--texte:#e6e9ed;--doux:#98a2b0;--bord:#2d353f;--ok:#56d364;
--ok-fond:#12351d;--ko:#ff7b72;--ko-fond:#3d1518;--revoir:#e3b341;
--revoir-fond:#3a2d0b;--rejet-fond:#4a1a1a;--entete:#1f262e}}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--fond);color:var(--texte);
font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
main{max-width:1100px;margin:0 auto;padding:16px}
h1{font-size:1.5rem;margin:.2em 0 .5em}
h2{font-size:1.25rem;margin:1.6em 0 .5em;border-bottom:1px solid var(--bord);padding-bottom:.2em}
h3{font-size:1.1rem;margin:1.2em 0 .4em}
h4{font-size:.95rem;margin:.8em 0 .3em;color:var(--doux)}
code{font-family:ui-monospace,Consolas,monospace;font-size:.9em;overflow-wrap:anywhere}
.verdict{display:inline-block;font-size:2rem;font-weight:800;letter-spacing:.06em;
padding:.2em .7em;border-radius:10px;border:3px solid}
.verdict.ok{color:var(--ok);background:var(--ok-fond);border-color:var(--ok)}
.verdict.ko{color:var(--ko);background:var(--ko-fond);border-color:var(--ko)}
.meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px 16px;margin:16px 0 0}
.meta div{background:var(--carte);border:1px solid var(--bord);border-radius:8px;padding:8px 12px;min-width:0}
.meta dt{font-size:.8rem;color:var(--doux)}
.meta dd{margin:0;font-weight:600;overflow-wrap:anywhere}
.liste{margin:.3em 0;padding-left:1.3em}
.liste li{overflow-wrap:anywhere}
.ko-liste li{color:var(--ko)}
.vide{color:var(--doux);font-style:italic}
.seuils{display:flex;flex-wrap:wrap;gap:6px 14px;list-style:none;padding:0;margin:.3em 0}
.seuils li{background:var(--carte);border:1px solid var(--bord);border-radius:6px;padding:2px 10px}
.cartes{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:.6em 0}
.carte{background:var(--carte);border:1px solid var(--bord);border-radius:8px;padding:8px 12px;
display:flex;flex-direction:column}
.carte .k{font-size:.8rem;color:var(--doux)}
.carte .v{font-size:1.2rem;font-weight:700}
.defile{overflow-x:auto;max-width:100%;border:1px solid var(--bord);border-radius:8px;background:var(--carte)}
table{border-collapse:collapse;width:100%;font-size:.88rem}
th,td{padding:4px 8px;border-bottom:1px solid var(--bord);text-align:left;white-space:nowrap}
th{background:var(--entete);position:sticky;top:0}
th small{font-weight:400;color:var(--doux)}
td.num{text-align:right;font-variant-numeric:tabular-nums}
td.clip{font-weight:600}
small.os{display:block;font-size:.7rem;color:var(--doux);line-height:1.1}
tr.v-rejete td{background:var(--rejet-fond)}
tr.v-revoir td{background:var(--revoir-fond)}
td.m-revoir{color:var(--revoir);font-weight:700}
td.m-rejet{color:var(--ko);font-weight:800}
tr.ajuste td{background:var(--revoir-fond)}
.puce{display:inline-block;padding:0 .6em;border-radius:999px;font-size:.8rem;font-weight:700;
border:1px solid var(--bord)}
.puce.accepte{color:var(--ok);background:var(--ok-fond);border-color:var(--ok)}
.puce.arevoir,.puce.revoir{color:var(--revoir);background:var(--revoir-fond);border-color:var(--revoir)}
.puce.rejete{color:var(--ko);background:var(--ko-fond);border-color:var(--ko)}
.deux{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
.deux>div{min-width:0}
details{margin:.8em 0}
summary{cursor:pointer;font-weight:600;padding:4px 0}
.ecarts th{position:static}
footer{margin:2em 0 1em;color:var(--doux);font-size:.8rem}
"""
def construire(dossier):
rapport = lire_json(os.path.join(dossier, "rapport.json"))
date_txt = datetime.datetime.now().strftime("%d/%m/%Y %H:%M:%S")
parts = ["<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
"<meta name=\"color-scheme\" content=\"light dark\">\n"
"<title>Rapport des contr\u00f4les d'animations</title>\n"
"<style>%s</style>\n</head>\n<body>\n<main>\n" % CSS]
parts.append(html_entete(rapport, date_txt))
parts.append(html_echecs_revoir(rapport))
parts.append(html_legende())
stats = {}
for c in rapport.get("corps", []):
nom = c.get("corps", "?")
chemin_ctrl = os.path.join(dossier, "controles_%s.csv" % nom)
chemin_eval = os.path.join(dossier, "eval_%s.csv" % nom)
chemin_pose = os.path.join(dossier, "rapport_poseT_%s.json" % nom)
ctrl = lire_csv(chemin_ctrl) if os.path.isfile(chemin_ctrl) else []
evals = lire_csv(chemin_eval) if os.path.isfile(chemin_eval) else []
pose = lire_json(chemin_pose) if os.path.isfile(chemin_pose) else None
stats[nom] = (len(ctrl), len(evals))
parts.append('<section class="corps" id="corps-%s">\n<h2>Corps : %s</h2>\n'
% (esc(nom), esc(nom)))
parts.append(html_resume_corps(c))
parts.append("<h3>Contr\u00f4les par clip</h3>\n")
if ctrl:
parts.append(html_controles(ctrl))
else:
parts.append('<p class="vide">Fichier controles_%s.csv absent ou vide.</p>\n'
% esc(nom))
parts.append("<h3>Pose T</h3>\n")
parts.append(html_pose(pose))
parts.append("<h3>\u00c9carts d\u00e9taill\u00e9s</h3>\n")
if evals:
parts.append(html_ecarts(evals))
else:
parts.append('<p class="vide">Fichier eval_%s.csv absent ou vide.</p>\n'
% esc(nom))
parts.append("</section>\n")
parts.append('<footer>Rapport g\u00e9n\u00e9r\u00e9 par RapportControles.py \u00b7 '
'source : <code>%s</code></footer>\n</main>\n</body>\n</html>\n'
% esc(dossier))
sortie = os.path.join(dossier, "rapport.html")
with open(sortie, "w", encoding="utf-8", newline="\n") as f:
f.write("".join(parts))
return sortie, stats
def main(argv):
dossier = argv[1] if len(argv) > 1 else DOSSIER_DEFAUT
if not os.path.isfile(os.path.join(dossier, "rapport.json")):
print("rapport.json introuvable dans : %s" % dossier, file=sys.stderr)
return 1
sortie, stats = construire(dossier)
print("Rapport ecrit : %s" % sortie)
for nom, (nc, ne) in stats.items():
print(" %s : %d lignes de controles, %d lignes d'ecarts" % (nom, nc, ne))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View file

@ -1,463 +0,0 @@
# Recopie les clips d'un pack d'animations sur Bridget et Michael (squelettes Genesis 8 du jeu).
# Les chaines portent le meme nom des deux cotes pour que le retarget les aligne.
# Les maillages bridget_gp et michael ne sont pas reimportes.
# Lot AP7 : script generique (ex-RetargetQuaternius.py), pilote par une methode de transformation
# (Tools/anim/methodes/<id>.json ; "ue4" = ancienne methode Quaternius) et par Saved/anim_retarget.json.
#
# Lance par Tools/BuildMod.ps1 -Retarget (UnrealEditor.exe : la copie a besoin de Slate).
# Saved/anim_retarget.json (absent : configuration Quaternius par defaut), champs et defauts :
# mode "bake"|"rigs" (defaut "bake" ; a defaut de "mode", Saved/anim_to_genesis_step.txt est lu comme avant),
# methode "ue4", source_mesh /Game/Quaternius/UAL/UAL1_Standard.UAL1_Standard, clips_dir /Game/Quaternius/UAL,
# out_dir = clips_dir, dest /Game/Quaternius/Retarget (ue4) ou /Game/Retarget_<methode>,
# corps [{nom,maillage,prefixe}] Bridget (B_) et Michael (M_), exclure [], pose_dir Saved/Measurement,
# jeton (ecrit par BuildMod, ajoute en fin des lignes de resultat et d'echec : "... jeton <jeton>").
# Methode factice ou copie, ou profil du maillage source different de profil_source : ligne
# "RETARGET echec : <cause>" puis arret.
#
# Phase C (CHG-204) : la pose de retarget de chaque corps est la pose ajustee sur le mannequin
# par la sandbox Mesure (LPPoseMeasure::WriteRetargetPose) : Saved/Measurement/retarget_pose_<Corps>.txt.
# Colonne et cou ont plus d'os chez Genesis : chaines Interpolated. Les os Twist restent hors chaine.
# AP1 : les chaines et la racine viennent des profils Tools/anim/profiles (identification par les noms d'os).
import json
import math
import os
import unreal
DEFAULT_CONFIG = {
"mode": None,
"methode": "ue4",
"source_mesh": "/Game/Quaternius/UAL/UAL1_Standard.UAL1_Standard",
"clips_dir": "/Game/Quaternius/UAL",
"out_dir": None,
"dest": None,
"corps": [
{"nom": "Bridget", "maillage": "/Game/Heavy/Daz/Bridget/bridget_gp.bridget_gp", "prefixe": "B_"},
{"nom": "Michael", "maillage": "/Game/Heavy/Daz/Michael/michael.michael", "prefixe": "M_"},
],
"exclure": [],
"pose_dir": None,
"jeton": None,
}
NONE = unreal.Name()
CFG = {}
JETON = None
# Chaines derivees des profils de squelettes (lot AP1) : Tools/anim/profiles/*.json, lus aussi par la
# sandbox (LPSkeletonProfile). Une chaine ne traverse jamais un os *Twist (hors chaine, il suit l'os Bend).
HERE = os.path.dirname(os.path.abspath(__file__))
PROFILE_DIR = os.path.join(HERE, "profiles")
METHOD_DIR = os.path.join(HERE, "methodes")
SIDES = (("l", "Left"), ("r", "Right"))
FINGERS = (("thumb", "Thumb"), ("index", "Index"), ("middle", "Mid"), ("ring", "Ring"), ("pinky", "Pinky"))
class Refusal(Exception):
pass
def load_config(saved_dir):
# Configuration par defaut = comportement Quaternius ; Saved/anim_retarget.json la complete champ par champ.
config = json.loads(json.dumps(DEFAULT_CONFIG))
path = os.path.join(saved_dir, "anim_retarget.json")
if os.path.exists(path):
with open(path, "r", encoding="utf-8-sig") as handle:
given = json.load(handle)
global JETON
JETON = str(given["jeton"]) if given.get("jeton") else None
for key, value in given.items():
if key not in DEFAULT_CONFIG:
raise Refusal("champ inconnu %s dans %s" % (key, path))
config[key] = value
if config["mode"] is None:
config["mode"] = "bake"
legacy = os.path.join(saved_dir, "anim_to_genesis_step.txt")
if os.path.exists(legacy):
with open(legacy, "r", encoding="utf-8") as handle:
config["mode"] = handle.read().strip().lower() or "bake"
config["mode"] = str(config["mode"]).strip().lower()
if config["mode"] not in ("bake", "rigs"):
raise Refusal("etape inconnue %s" % config["mode"])
config["clips_dir"] = config["clips_dir"].rstrip("/")
config["out_dir"] = (config["out_dir"] or config["clips_dir"]).rstrip("/")
if not config["dest"]:
config["dest"] = "/Game/Quaternius/Retarget" if config["methode"] == "ue4" else "/Game/Retarget_%s" % config["methode"]
config["dest"] = config["dest"].rstrip("/")
if not config["pose_dir"]:
config["pose_dir"] = os.path.join(saved_dir, "Measurement")
if not config["corps"]:
raise Refusal("aucun corps dans la configuration")
return config
def load_method(method_id, method_dir=None):
path = os.path.join(method_dir or METHOD_DIR, "%s.json" % method_id)
if not os.path.exists(path):
raise Refusal("methode inconnue %s" % method_id)
with open(path, "r", encoding="utf-8") as handle:
method = json.load(handle)
if method.get("factice") or method.get("copie"):
raise Refusal("methode %s non implementee (factice ou copie)" % method_id)
return method
def suffix(config):
# Les assets de la methode ue4 gardent leurs noms historiques ; les autres sont suffixes par la methode.
return "" if config["methode"] == "ue4" else "_" + config["methode"]
def source_rig_name(config):
return "IK_Quaternius" if config["methode"] == "ue4" else "IK_Source" + suffix(config)
def load_profiles():
profiles = []
for name in sorted(os.listdir(PROFILE_DIR)):
if name.endswith(".json"):
with open(os.path.join(PROFILE_DIR, name), "r", encoding="utf-8") as handle:
profiles.append(json.load(handle))
if not profiles:
raise RuntimeError("aucun profil de squelette dans %s" % PROFILE_DIR)
return profiles
def role(profile, name, index=0):
value = profile["roles"].get(name)
if value is None:
return None
if isinstance(value, list):
return value[index] if -len(value) <= index < len(value) else None
return value if index in (0, -1) else None
def identify_names(names, label="?"):
# Meme regle que LPSkeletonProfile::Identify : au moins 95 % des os du profil presents ; a egalite de
# score, le profil qui reconnait le plus d'os (le mannequin UE5 contient tous les noms d'os UE4).
names = set(names)
best, best_score, best_hits = None, 0.0, 0
for profile in load_profiles():
bones = []
for value in profile["roles"].values():
bones.extend(value if isinstance(value, list) else [value])
hits = sum(1 for bone in bones if bone in names)
score = round(hits / float(len(bones)), 6)
if score > best_score or (score == best_score and hits > best_hits):
best, best_score, best_hits = profile, score, hits
if best is None or best_score < 0.95:
raise RuntimeError("squelette inconnu %s (meilleur profil %.0f%%)" % (label, best_score * 100.0))
return best, best_score
def identify(mesh):
best, best_score = identify_names(bone_names(mesh), mesh.get_name())
unreal.log_warning("RETARGET profil %s -> %s (%.0f%%)" % (mesh.get_name(), best["id"], best_score * 100.0))
return best
def chains(profile):
# (nom de chaine, premier os, dernier os) : memes noms des deux cotes pour auto_map_chains.
made = [("Spine", role(profile, "spine", 0), role(profile, "spine", -1)),
("Neck", role(profile, "neck", 0), role(profile, "head"))]
for side, word in SIDES:
made += [
(word + "Collar", role(profile, "clavicle_" + side), role(profile, "upperarm_" + side)),
(word + "Forearm", role(profile, "lowerarm_" + side), role(profile, "lowerarm_" + side)),
(word + "Hand", role(profile, "hand_" + side), role(profile, "hand_" + side)),
(word + "Thigh", role(profile, "thigh_" + side), role(profile, "thigh_" + side)),
(word + "Calf", role(profile, "calf_" + side), role(profile, "calf_" + side)),
(word + "Foot", role(profile, "foot_" + side), role(profile, "foot_" + side)),
(word + "Toe", role(profile, "toe_" + side), role(profile, "toe_" + side)),
]
for finger, daz in FINGERS:
made.append((word + daz, role(profile, finger + "_" + side, 0), role(profile, finger + "_" + side, -1)))
missing = [name for name, start, end in made if not start or not end]
if missing:
raise RuntimeError("profil %s : chaines sans os %s" % (profile["id"], ", ".join(missing)))
return made
INTERPOLATED = ("Spine", "Neck")
def make_factory(factory_class):
factory = factory_class()
factory.set_editor_property("edit_after_new", False)
return factory
def vsub(a, b):
return unreal.MathLibrary.subtract_vector_vector(a, b)
def vnorm(v):
return unreal.MathLibrary.normal(v)
def vdot(a, b):
return a.x * b.x + a.y * b.y + a.z * b.z
def vcross(a, b):
return unreal.MathLibrary.cross_vector_vector(a, b)
def bone_names(mesh):
pose = mesh.skeleton.get_reference_pose()
return [str(name) for name in unreal.AnimPoseExtensions.get_bone_names(pose)]
def pose_file(body):
return os.path.join(CFG["pose_dir"], "retarget_pose_%s.txt" % body)
def read_pose(body):
path = pose_file(body)
if not os.path.exists(path):
raise RuntimeError("pose de retarget absente %s : ouvrir la sandbox Mesure une fois" % path)
offsets = []
with open(path, "r", encoding="utf-8-sig") as handle:
for line in handle:
cells = line.split()
if len(cells) != 5 or cells[0].startswith("#"):
continue
offsets.append((cells[0], unreal.Quat(float(cells[1]), float(cells[2]), float(cells[3]), float(cells[4]))))
return offsets
def apply_pose(ctrl, mesh, body):
side = unreal.RetargetSourceOrTarget.TARGET
names = set(bone_names(mesh))
applied = 0
for bone, quat in read_pose(body):
if bone not in names:
unreal.log_warning("RETARGET pose %s os absent %s" % (body, bone))
continue
ctrl.set_rotation_offset_for_retarget_pose_bone(bone, quat, side)
applied += 1
unreal.log_warning("RETARGET pose %s %d os (pose %s ajustee)" % (body, applied, CFG["pose"]))
def make_rig(name, mesh_path):
asset_path = CFG["dest"] + "/" + name
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
unreal.EditorAssetLibrary.delete_asset(asset_path)
mesh = unreal.load_asset(mesh_path)
profile = identify(mesh)
root = role(profile, "hips")
rig_chains = chains(profile)
rig = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
name, CFG["dest"], unreal.IKRigDefinition, make_factory(unreal.IKRigDefinitionFactory))
ctrl = unreal.IKRigController.get_controller(rig)
ctrl.set_skeletal_mesh(mesh)
ctrl.set_retarget_root(root)
for chain, start, end in rig_chains:
made = str(ctrl.add_retarget_chain(chain, start, end, NONE))
unreal.log_warning("RETARGET chaine %s %s %s -> %s" % (name, chain, start, made))
if made == "None":
raise RuntimeError("chaine impossible %s %s %s" % (name, chain, start))
unreal.EditorAssetLibrary.save_loaded_asset(rig)
return rig, mesh, rig_chains
def make_retargeter(name, source_rig, target_rig, source_mesh, target_mesh, body, source_chains):
asset_path = CFG["dest"] + "/" + name
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
unreal.EditorAssetLibrary.delete_asset(asset_path)
retargeter = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
name, CFG["dest"], unreal.IKRetargeter, make_factory(unreal.IKRetargetFactory))
ctrl = unreal.IKRetargeterController.get_controller(retargeter)
ctrl.set_ik_rig(unreal.RetargetSourceOrTarget.SOURCE, source_rig)
ctrl.set_ik_rig(unreal.RetargetSourceOrTarget.TARGET, target_rig)
ctrl.set_preview_mesh(unreal.RetargetSourceOrTarget.SOURCE, source_mesh)
ctrl.set_preview_mesh(unreal.RetargetSourceOrTarget.TARGET, target_mesh)
ctrl.auto_map_chains(unreal.AutoMapChainType.EXACT, True)
# Pose de retarget : la pose ajustee de la sandbox, et non plus l'alignement par le maillage,
# les reglages Daz et les demi-tours d'epaule (pole / roll180), qu'elle remplace.
apply_pose(ctrl, target_mesh, body)
for chain_name, _, _ in source_chains:
settings = ctrl.get_retarget_chain_settings(chain_name)
fk = settings.get_editor_property("fk")
mode = unreal.RetargetRotationMode.INTERPOLATED if chain_name in INTERPOLATED else unreal.RetargetRotationMode.ONE_TO_ONE
fk.set_editor_property("rotation_mode", mode)
fk.set_editor_property("translation_mode", unreal.RetargetTranslationMode.NONE)
fk.set_editor_property("pole_vector_matching", 0.0)
settings.set_editor_property("fk", fk)
ctrl.set_retarget_chain_settings(chain_name, settings)
unreal.EditorAssetLibrary.save_loaded_asset(retargeter)
return retargeter
def source_clips():
prefixes = tuple(body["prefixe"] for body in CFG["corps"])
clips = []
for path in unreal.EditorAssetLibrary.list_assets(CFG["clips_dir"], False, False):
asset = unreal.load_asset(path)
if not isinstance(asset, unreal.AnimSequence):
continue
name = asset.get_name()
if name.startswith(prefixes) or name in CFG["exclure"]:
continue
clips.append(unreal.EditorAssetLibrary.find_asset_data(path))
return clips
def delete_copies(names):
# Seulement les copies des clips source. Pas tout asset dont le nom commence par un prefixe de corps.
for name in names:
for body in CFG["corps"]:
for folder in (CFG["out_dir"] + "/", "/Game/"):
path = folder + body["prefixe"] + name
if unreal.EditorAssetLibrary.does_asset_exist(path):
unreal.EditorAssetLibrary.delete_asset(path)
def tag(line):
# AP7 : BuildMod ne retient que les lignes de resultat portant son jeton (cle "jeton" de anim_retarget.json).
return "%s jeton %s" % (line, JETON) if JETON else line
def finish(line):
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
unreal.log_warning(tag(line))
unreal.SystemLibrary.quit_editor()
def anim_pose(sequence, time):
try:
options = unreal.AnimPoseEvaluationOptions()
for prop in ("should_retarget", "b_should_retarget"):
try:
options.set_editor_property(prop, False)
except Exception:
pass
return unreal.AnimPoseExtensions.get_anim_pose_at_time(sequence, time, options)
except Exception as exc:
unreal.log_warning("RETARGET pose api %s" % exc)
return unreal.AnimPoseExtensions.get_anim_pose_at_time(sequence, time)
def hand_ahead(sequence, chest_name, hand_name, left_name, right_name, time, pelvis_name="pelvis"):
pose = anim_pose(sequence, time)
space = unreal.AnimPoseSpaces.WORLD
def loc(bone):
return unreal.AnimPoseExtensions.get_bone_pose(pose, bone, space).translation
chest = loc(chest_name)
pelvis = loc(pelvis_name)
up = vnorm(vsub(chest, pelvis))
side = vnorm(vsub(loc(left_name), loc(right_name)))
forward = vnorm(vcross(side, up))
return vdot(vsub(loc(hand_name), chest), forward)
def log_hands(label):
# Releve seulement : main devant (+) ou derriere (-) le buste sur le premier clip cuit du pack.
# La mesure fine est la tournee de la sandbox. Sans clip cuit ou sans roles : on saute.
profile = CFG["source_profile"]
chest_src = role(profile, "spine", -1)
hips_src = role(profile, "hips")
hands_src = (role(profile, "hand_l"), role(profile, "hand_r"))
collars_src = (role(profile, "clavicle_l"), role(profile, "clavicle_r"))
if not (chest_src and hips_src and all(hands_src) and all(collars_src)):
unreal.log_warning("RETARGET mesure sautee : roles du profil source incomplets")
return
first = None
for body in CFG["corps"]:
for data in source_clips():
name = str(data.asset_name)
if unreal.EditorAssetLibrary.does_asset_exist(CFG["out_dir"] + "/" + body["prefixe"] + name):
first = name
break
if first:
break
if first is None:
unreal.log_warning("RETARGET mesure sautee : aucun clip cuit")
return
spec = [(CFG["clips_dir"] + "/" + first, chest_src, hips_src, hands_src, collars_src)]
for body in CFG["corps"]:
spec.append((CFG["out_dir"] + "/" + body["prefixe"] + first, "chestUpper", "pelvis", ("lHand", "rHand"), ("lCollar", "rCollar")))
for path, chest, pelvis, hands, collars in spec:
leaf = path.split("/")[-1]
sequence = unreal.load_asset(path + "." + leaf)
if not sequence:
unreal.log_warning("RETARGET mesure absente %s" % path)
continue
for hand in hands:
ahead = hand_ahead(sequence, chest, hand, collars[0], collars[1], 0.0, pelvis)
unreal.log_warning("RETARGET main %s %s %s devant %.1f" % (label, leaf, hand, ahead))
def bake_named(clips, source_mesh, bodies):
# bodies : [(corps, maillage, retargeter)] ; renvoie (nombre de copies par corps, nombre deplaces).
made = []
for body, mesh, retargeter in bodies:
made.append(unreal.IKRetargetBatchOperation.duplicate_and_retarget(
clips, source_mesh, mesh, retargeter, "", "", body["prefixe"], "", True))
moved = 0
for data in [item for group in made for item in group]:
package = str(data.package_name)
name = str(data.asset_name)
dest = CFG["out_dir"] + "/" + name
if package == dest or unreal.EditorAssetLibrary.rename_asset(package, dest):
moved += 1
else:
unreal.log_error("RETARGET deplacement rate %s" % package)
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
return [len(group) for group in made], moved
def run():
global CFG
CFG = load_config(unreal.Paths.project_saved_dir())
method = load_method(CFG["methode"])
CFG["pose"] = method["pose"]
mode = CFG["mode"]
unreal.log_warning("RETARGET etape %s" % mode)
suf = suffix(CFG)
probe = unreal.load_asset(CFG["source_mesh"])
if not probe:
raise Refusal("maillage source introuvable %s" % CFG["source_mesh"])
source_profile, _ = identify_names(bone_names(probe), probe.get_name())
if source_profile["id"] != method["profil_source"]:
raise Refusal("profil du maillage source %s (%s) different de profil_source %s de la methode %s" % (
probe.get_name(), source_profile["id"], method["profil_source"], CFG["methode"]))
CFG["source_profile"] = source_profile
unreal.log_warning("RETARGET methode %s profil %s calques %s" % (CFG["methode"], source_profile["id"], method["pose"]))
source_rig, source_mesh, source_chains = make_rig(source_rig_name(CFG), CFG["source_mesh"])
bodies = []
for body in CFG["corps"]:
rig, mesh, _ = make_rig("IK_" + body["nom"] + suf, body["maillage"])
retargeter = make_retargeter("RTG_" + body["nom"] + suf, source_rig, rig, source_mesh, mesh, body["nom"], source_chains)
bodies.append((body, mesh, retargeter))
if mode == "rigs":
finish("RETARGET rigs prets")
return
names = [str(data.asset_name) for data in source_clips()]
delete_copies(names)
clips = source_clips()
unreal.log_warning("RETARGET clips source %d" % len(clips))
counts, moved = bake_named(clips, source_mesh, bodies)
try:
log_hands("final")
except Exception as exc:
unreal.log_warning("RETARGET mesure finale ratee %s" % exc)
done = " ".join("%s %d" % (body["nom"].lower(), count) for (body, _, _), count in zip(bodies, counts))
finish("RETARGET %s deplaces %d pose %s ajustee" % (done, moved, method["pose"]))
def main():
try:
run()
except Refusal as exc:
unreal.log_warning(tag("RETARGET echec : %s" % exc))
unreal.SystemLibrary.quit_editor()
except Exception as exc:
unreal.log_warning(tag("RETARGET echec : %s" % exc))
unreal.SystemLibrary.quit_editor()
raise
if not os.environ.get("LP_RETARGET_NOMAIN"):
main()

View file

@ -1,12 +0,0 @@
{
"id": "genesis_direct",
"nom": "Genesis 8 vers Genesis 8 (copie)",
"profil_source": "genesis8",
"pose": "A",
"copie": true,
"factice": true,
"description": "Source Daz (DUF) sur le meme squelette que le corps : copie sans transformation. Methode declaree, non implementee : la chaine la refuse avec un message clair.",
"exemples": [
"poses et animations Daz Studio (.duf)"
]
}

View file

@ -1,10 +0,0 @@
{
"id": "ue4",
"nom": "Mannequin UE4 vers Genesis 8 (T vers T)",
"profil_source": "ue4_mannequin",
"pose": "T",
"description": "Le mannequin UE4 est livre en pose T : calque d'origine = repos du mannequin ; calque du corps = repos Genesis + reglages Daz A vers T du profil genesis8, ajuste a la morphologie (phase A). Ancienne methode 'Quaternius'.",
"exemples": [
"Quaternius Universal Animation Library"
]
}

View file

@ -1,10 +0,0 @@
{
"id": "ue5",
"nom": "Mannequin UE5 vers Genesis 8 (A vers A)",
"profil_source": "ue5_mannequin",
"pose": "A",
"description": "Le mannequin UE5 est livre en pose A, comme Bridget et Michael : calque d'origine = repos du mannequin ; calque du corps = repos Genesis (A), ajuste a la morphologie (phase A). Aucun reglage A vers T.",
"exemples": [
"FreeAnimationsPack (SK_Mannequin Manny / Quinn)"
]
}

View file

@ -1,56 +0,0 @@
{
"id": "genesis8",
"nom": "Daz Genesis 8 (Bridget, Michael)",
"description": "Repos en pose A. Le pelvis Genesis est un enfant de hip, frere d'abdomenLower : la colonne part d'abdomenLower. Les os Twist (epaule, avant-bras, cuisse) recoivent la torsion.",
"pose_t": {
"type": "reglages",
"source": "molettes Daz publiees, pose A Genesis 8 vers T (CHG-183)",
"reglages": [
{ "os": "lCollar", "enfant": "lShldrBend", "cible": "plat", "degres": 2.5 },
{ "os": "rCollar", "enfant": "rShldrBend", "cible": "plat", "degres": 2.5 },
{ "os": "lShldrBend", "enfant": "lForearmBend", "cible": "plat", "degres": 43.5 },
{ "os": "rShldrBend", "enfant": "rForearmBend", "cible": "plat", "degres": 43.5 },
{ "os": "lThighBend", "enfant": "lShin", "cible": "bas", "degres": 6.0 },
{ "os": "rThighBend", "enfant": "rShin", "cible": "bas", "degres": 6.0 }
]
},
"roles": {
"hips": "hip",
"pelvis": "pelvis",
"spine": ["abdomenLower", "abdomenUpper", "chestLower", "chestUpper"],
"neck": ["neckLower", "neckUpper"],
"head": "head",
"clavicle_l": "lCollar",
"upperarm_l": "lShldrBend",
"upperarm_twist_l": "lShldrTwist",
"lowerarm_l": "lForearmBend",
"lowerarm_twist_l": "lForearmTwist",
"hand_l": "lHand",
"thumb_l": ["lThumb1", "lThumb2", "lThumb3"],
"index_l": ["lIndex1", "lIndex2", "lIndex3"],
"middle_l": ["lMid1", "lMid2", "lMid3"],
"ring_l": ["lRing1", "lRing2", "lRing3"],
"pinky_l": ["lPinky1", "lPinky2", "lPinky3"],
"thigh_l": "lThighBend",
"thigh_twist_l": "lThighTwist",
"calf_l": "lShin",
"foot_l": "lFoot",
"toe_l": "lToe",
"clavicle_r": "rCollar",
"upperarm_r": "rShldrBend",
"upperarm_twist_r": "rShldrTwist",
"lowerarm_r": "rForearmBend",
"lowerarm_twist_r": "rForearmTwist",
"hand_r": "rHand",
"thumb_r": ["rThumb1", "rThumb2", "rThumb3"],
"index_r": ["rIndex1", "rIndex2", "rIndex3"],
"middle_r": ["rMid1", "rMid2", "rMid3"],
"ring_r": ["rRing1", "rRing2", "rRing3"],
"pinky_r": ["rPinky1", "rPinky2", "rPinky3"],
"thigh_r": "rThighBend",
"thigh_twist_r": "rThighTwist",
"calf_r": "rShin",
"foot_r": "rFoot",
"toe_r": "rToe"
}
}

View file

@ -1,39 +0,0 @@
{
"id": "ue4_mannequin",
"nom": "Mannequin UE4 (nomenclature SK_Mannequin) : Quaternius Universal Animation Library",
"description": "Repos en pose T. Roles : un nom d'os, ou une liste ordonnee de la racine vers le bout pour une chaine.",
"pose_t": { "type": "repos", "reglages": [] },
"roles": {
"hips": "pelvis",
"pelvis": "pelvis",
"spine": ["spine_01", "spine_02", "spine_03"],
"neck": ["neck_01"],
"head": "Head",
"clavicle_l": "clavicle_l",
"upperarm_l": "upperarm_l",
"lowerarm_l": "lowerarm_l",
"hand_l": "hand_l",
"thumb_l": ["thumb_01_l", "thumb_02_l", "thumb_03_l"],
"index_l": ["index_01_l", "index_02_l", "index_03_l"],
"middle_l": ["middle_01_l", "middle_02_l", "middle_03_l"],
"ring_l": ["ring_01_l", "ring_02_l", "ring_03_l"],
"pinky_l": ["pinky_01_l", "pinky_02_l", "pinky_03_l"],
"thigh_l": "thigh_l",
"calf_l": "calf_l",
"foot_l": "foot_l",
"toe_l": "ball_l",
"clavicle_r": "clavicle_r",
"upperarm_r": "upperarm_r",
"lowerarm_r": "lowerarm_r",
"hand_r": "hand_r",
"thumb_r": ["thumb_01_r", "thumb_02_r", "thumb_03_r"],
"index_r": ["index_01_r", "index_02_r", "index_03_r"],
"middle_r": ["middle_01_r", "middle_02_r", "middle_03_r"],
"ring_r": ["ring_01_r", "ring_02_r", "ring_03_r"],
"pinky_r": ["pinky_01_r", "pinky_02_r", "pinky_03_r"],
"thigh_r": "thigh_r",
"calf_r": "calf_r",
"foot_r": "foot_r",
"toe_r": "ball_r"
}
}

View file

@ -1,129 +0,0 @@
{
"id": "ue5_mannequin",
"nom": "Mannequin UE5 (SK_Mannequin Manny / Quinn) : FreeAnimationsPack et packs Epic UE5",
"description": "Repos en pose A (bras 54,8 deg sous l'horizontale). Colonne a 5 os, cou a 2 os. Os de torsion, metacarpes et correctifs hors roles (pilotes, non transferes). Roles : un nom d'os, ou une liste ordonnee de la racine vers le bout pour une chaine.",
"pose_t": {
"type": "reglages",
"source": "Reglages A vers T utilises seulement par une methode en pose T (comparaison) ; la methode ue5 (pose A) prend le repos tel quel. repos SK_Mannequin releve par -run=LPAnimIdentify (AP7) ; cibles = pose T du mannequin Quaternius (clavicule 4,42 deg sous l'horizontale, cuisse 0,29 deg) ; reglages cumules du parent a l'enfant (bras 54,80 - 4,29)",
"reglages": [
{
"os": "clavicle_l",
"enfant": "upperarm_l",
"cible": "plat",
"degres": 4.29
},
{
"os": "clavicle_r",
"enfant": "upperarm_r",
"cible": "plat",
"degres": 4.29
},
{
"os": "upperarm_l",
"enfant": "lowerarm_l",
"cible": "plat",
"degres": 50.51
},
{
"os": "upperarm_r",
"enfant": "lowerarm_r",
"cible": "plat",
"degres": 50.51
},
{
"os": "thigh_l",
"enfant": "calf_l",
"cible": "bas",
"degres": 2.76
},
{
"os": "thigh_r",
"enfant": "calf_r",
"cible": "bas",
"degres": 2.76
}
]
},
"roles": {
"hips": "pelvis",
"pelvis": "pelvis",
"spine": [
"spine_01",
"spine_02",
"spine_03",
"spine_04",
"spine_05"
],
"neck": [
"neck_01",
"neck_02"
],
"head": "head",
"clavicle_l": "clavicle_l",
"upperarm_l": "upperarm_l",
"lowerarm_l": "lowerarm_l",
"hand_l": "hand_l",
"thumb_l": [
"thumb_01_l",
"thumb_02_l",
"thumb_03_l"
],
"index_l": [
"index_01_l",
"index_02_l",
"index_03_l"
],
"middle_l": [
"middle_01_l",
"middle_02_l",
"middle_03_l"
],
"ring_l": [
"ring_01_l",
"ring_02_l",
"ring_03_l"
],
"pinky_l": [
"pinky_01_l",
"pinky_02_l",
"pinky_03_l"
],
"thigh_l": "thigh_l",
"calf_l": "calf_l",
"foot_l": "foot_l",
"toe_l": "ball_l",
"clavicle_r": "clavicle_r",
"upperarm_r": "upperarm_r",
"lowerarm_r": "lowerarm_r",
"hand_r": "hand_r",
"thumb_r": [
"thumb_01_r",
"thumb_02_r",
"thumb_03_r"
],
"index_r": [
"index_01_r",
"index_02_r",
"index_03_r"
],
"middle_r": [
"middle_01_r",
"middle_02_r",
"middle_03_r"
],
"ring_r": [
"ring_01_r",
"ring_02_r",
"ring_03_r"
],
"pinky_r": [
"pinky_01_r",
"pinky_02_r",
"pinky_03_r"
],
"thigh_r": "thigh_r",
"calf_r": "calf_r",
"foot_r": "foot_r",
"toe_r": "ball_r"
}
}

View file

@ -1,7 +0,0 @@
@echo off
rem Meme chambre que la sandbox pair. Le menu en haut a gauche choisit Interaction ou Pair.
rem L'instance de jeu du Blueprint n'est pas chargee. Aucun Blueprint n'est modifie.
setlocal
set "UE=D:\Game\Launcher\Engine\UE_5.5\Engine\Binaries\Win64\UnrealEditor.exe"
set "PROJ=%~dp0..\..\LifePlay.uproject"
start "" "%UE%" "%PROJ%" "/Engine/Maps/Entry?game=/Script/LPSandbox.LPPairSandboxGameMode" -game -windowed -ResX=1600 -ResY=900 "-ini:Engine:[/Script/EngineSettings.GameMapsSettings]:GameInstanceClass=/Script/Engine.GameInstance"

View file

@ -1,9 +0,0 @@
@echo off
rem Chambre vide measurement : mannequin, Bridget et Michael, pose figee.
rem L'instance de jeu du Blueprint n'est pas chargee. Aucun Blueprint n'est modifie.
setlocal
set "ROOT=%~dp0..\.."
echo Mesure>"%ROOT%\Saved\sandbox_mode.txt"
set "UE=D:\Game\Launcher\Engine\UE_5.5\Engine\Binaries\Win64\UnrealEditor.exe"
set "PROJ=%~dp0..\..\LifePlay.uproject"
start "" "%UE%" "%PROJ%" "/Engine/Maps/Entry?game=/Script/LPSandbox.LPPairSandboxGameMode" -game -windowed -ResX=1600 -ResY=900 "-ini:Engine:[/Script/EngineSettings.GameMapsSettings]:GameInstanceClass=/Script/Engine.GameInstance"

View file

@ -1,7 +0,0 @@
@echo off
rem Fenetre a part de la partie : chambre du bureau. Menu : Pair ou Interaction.
rem L'instance de jeu du Blueprint n'est pas chargee. Aucun Blueprint n'est modifie.
setlocal
set "UE=D:\Game\Launcher\Engine\UE_5.5\Engine\Binaries\Win64\UnrealEditor.exe"
set "PROJ=%~dp0..\..\LifePlay.uproject"
start "" "%UE%" "%PROJ%" "/Engine/Maps/Entry?game=/Script/LPSandbox.LPPairSandboxGameMode" -game -windowed -ResX=1600 -ResY=900 "-ini:Engine:[/Script/EngineSettings.GameMapsSettings]:GameInstanceClass=/Script/Engine.GameInstance"

View file

@ -1,151 +0,0 @@
{
"module": "lp_Revival",
"folder": "/Game/Heavy/New5/Hairs",
"backup": "D:/Game/LifePlay_Backup_5.5/HAIRS1",
"hairs": [
{
"name": "Pocolov03",
"archive": "D:/Game/pocolov-hair-03.zip",
"title": "Pocolov Hair 03",
"url": "https://www.fab.com/listings/2257098d-b77c-4eef-9132-85f1454f0ae3",
"mesh": "/Game/Heavy/New5/Hairs/_Work_Pocolov03/Box001",
"head": "/Game/Heavy/New5/Hairs/_Work_Pocolov03/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov03/norm/tex10_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov03/norm/tex12_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov06",
"archive": "D:/Game/pocolov-hair-06.zip",
"title": "Pocolov Hair 06",
"url": "https://www.fab.com/listings/14c3cd52-02f9-4bfe-bb40-b5c3acb1f428",
"mesh": "/Game/Heavy/New5/Hairs/_Work_Pocolov06/Hair_06",
"head": "/Game/Heavy/New5/Hairs/_Work_Pocolov06/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov06/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov06/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov11",
"archive": "D:/Game/pocolov-hair-11.zip",
"title": "Pocolov Hair 11",
"url": "https://www.fab.com/listings/c31c219e-bc62-4957-91c5-720440375f79",
"mesh": "/Game/Heavy/New5/Hairs/_Work_Pocolov11/Hair_11",
"head": "/Game/Heavy/New5/Hairs/_Work_Pocolov11/Head",
"cap_slots": "",
"diffuse": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov11/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov11/norm/tex04_Hair_Texture_A.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov15",
"archive": "D:/Game/pocolov-hair-15.zip",
"title": "Pocolov Hair 15",
"url": "https://www.fab.com/listings/ed8e016a-0bf0-4a6c-94b0-2760083b879d",
"mesh": "/Game/Heavy/New5/Hairs/_Work_Pocolov15/Hair_15",
"head": "/Game/Heavy/New5/Hairs/_Work_Pocolov15/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov15/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov15/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov16",
"archive": "D:/Game/pocolov-hair-16.zip",
"title": "Pocolov Hair 16",
"url": "https://www.fab.com/listings/802dc956-6211-4b83-8ba1-37beb3cb338f",
"mesh": "/Game/Heavy/New5/Hairs/_Work_Pocolov16/Hair_16",
"head": "/Game/Heavy/New5/Hairs/_Work_Pocolov16/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov16/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov16/norm/tex04_Hair_01A.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov17",
"archive": "D:/Game/pocolov-hair-17.zip",
"title": "Pocolov Hair 17",
"url": "https://www.fab.com/listings/7f6f687d-bb13-40c6-8b69-64044ededc1d",
"mesh": "/Game/Heavy/New5/Hairs/_Work_Pocolov17/Hair_17",
"head": "/Game/Heavy/New5/Hairs/_Work_Pocolov17/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov17/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov17/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio 2019, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
}
]
}

View file

@ -1,296 +0,0 @@
{
"mod": "lpr_hair_pocolov",
"module": "lpr_hair_pocolov",
"folder": "/lpr_hair_pocolov/Hairs",
"backup": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov",
"hairs": [
{
"name": "Pocolov03",
"archive": "D:/Game/pocolov-hair-03.zip",
"title": "Pocolov Hair 03",
"url": "https://www.fab.com/listings/2257098d-b77c-4eef-9132-85f1454f0ae3",
"mesh": "/lpr_hair_pocolov/Hairs/_Work_Pocolov03/Box001",
"head": "/lpr_hair_pocolov/Hairs/_Work_Pocolov03/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov03/norm/tex10_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov03/norm/tex12_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov05",
"archive": "D:/Game/pocolov-hair-05.zip",
"title": "Pocolov Hair 05",
"url": "https://www.fab.com/listings/4025749f-d597-4332-91aa-16e355b94974",
"mesh": "/lpr_hair_pocolov/Hairs/_Work_Pocolov05/Box001",
"head": "/lpr_hair_pocolov/Hairs/_Work_Pocolov05/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov05/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov05/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov06",
"archive": "D:/Game/pocolov-hair-06.zip",
"title": "Pocolov Hair 06",
"url": "https://www.fab.com/listings/14c3cd52-02f9-4bfe-bb40-b5c3acb1f428",
"mesh": "/lpr_hair_pocolov/Hairs/_Work_Pocolov06/Hair_06",
"head": "/lpr_hair_pocolov/Hairs/_Work_Pocolov06/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov06/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov06/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov07",
"archive": "D:/Game/pocolov-hair-07.zip",
"title": "Pocolov Hair 07",
"url": "https://www.fab.com/listings/4d1ba957-b420-4bdc-8984-b528a73b1d58",
"mesh": "/lpr_hair_pocolov/Hairs/_Work_Pocolov07/Hair_07",
"head": "/lpr_hair_pocolov/Hairs/_Work_Pocolov07/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov07/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov07/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov09",
"archive": "D:/Game/pocolov-hair-09.zip",
"title": "Pocolov Hair 09",
"url": "https://www.fab.com/listings/80383c46-f491-4db2-a63d-48d279f35782",
"mesh": "/lpr_hair_pocolov/Hairs/_Work_Pocolov09/Hair_09",
"head": "/lpr_hair_pocolov/Hairs/_Work_Pocolov09/Head",
"cap_slots": "",
"diffuse": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov09/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov09/norm/tex04_Hair_Texture_A.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov10",
"archive": "D:/Game/pocolov-hair-10.zip",
"title": "Pocolov Hair 10",
"url": "https://www.fab.com/listings/40a91685-76dc-4384-91f1-0040f44e8958",
"mesh": "/lpr_hair_pocolov/Hairs/_Work_Pocolov10/Hair_10",
"head": "/lpr_hair_pocolov/Hairs/_Work_Pocolov10/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov10/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov10/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov11",
"archive": "D:/Game/pocolov-hair-11.zip",
"title": "Pocolov Hair 11",
"url": "https://www.fab.com/listings/c31c219e-bc62-4957-91c5-720440375f79",
"mesh": "/lpr_hair_pocolov/Hairs/_Work_Pocolov11/Hair_11",
"head": "/lpr_hair_pocolov/Hairs/_Work_Pocolov11/Head",
"cap_slots": "",
"diffuse": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov11/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov11/norm/tex04_Hair_Texture_A.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov13",
"archive": "D:/Game/pocolov-hair-13.zip",
"title": "Pocolov Hair 13",
"url": "https://www.fab.com/listings/a3933681-f5e3-4d93-99f0-c5fff444d405",
"mesh": "/lpr_hair_pocolov/Hairs/_Work_Pocolov13/Hair_13",
"head": "/lpr_hair_pocolov/Hairs/_Work_Pocolov13/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov13/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov13/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov14",
"archive": "D:/Game/pocolov-hair-14.zip",
"title": "Pocolov Hair 14",
"url": "https://www.fab.com/listings/4911f6b9-d548-45d7-86be-a6971da97c54",
"mesh": "/lpr_hair_pocolov/Hairs/_Work_Pocolov14/Hair_14",
"head": "/lpr_hair_pocolov/Hairs/_Work_Pocolov14/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov14/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov14/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov15",
"archive": "D:/Game/pocolov-hair-15.zip",
"title": "Pocolov Hair 15",
"url": "https://www.fab.com/listings/ed8e016a-0bf0-4a6c-94b0-2760083b879d",
"mesh": "/lpr_hair_pocolov/Hairs/_Work_Pocolov15/Hair_15",
"head": "/lpr_hair_pocolov/Hairs/_Work_Pocolov15/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov15/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov15/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov16",
"archive": "D:/Game/pocolov-hair-16.zip",
"title": "Pocolov Hair 16",
"url": "https://www.fab.com/listings/802dc956-6211-4b83-8ba1-37beb3cb338f",
"mesh": "/lpr_hair_pocolov/Hairs/_Work_Pocolov16/Hair_16",
"head": "/lpr_hair_pocolov/Hairs/_Work_Pocolov16/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov16/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov16/norm/tex04_Hair_01A.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov17",
"archive": "D:/Game/pocolov-hair-17.zip",
"title": "Pocolov Hair 17",
"url": "https://www.fab.com/listings/7f6f687d-bb13-40c6-8b69-64044ededc1d",
"mesh": "/lpr_hair_pocolov/Hairs/_Work_Pocolov17/Hair_17",
"head": "/lpr_hair_pocolov/Hairs/_Work_Pocolov17/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov17/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/mods/lpr_hair_pocolov/Pocolov17/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio 2019, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
}
]
}