- Tools/BuildMod.ps1 : controles, cuisson DLC contre la version de reference, controle du conteneur, archive installable testee, installation optionnelle dans un jeu de test. - Tools/ModSource.ps1 : sources dans ModSources/<Nom>, montees dans Mods/ le temps d'un travail ; le build du jeu de base ne contient rien des mods. - HairPipeline en mode mod ; HairMaterials : analyse du dossier (plantage dans un plugin), reutilisation des textures ; sous-systeme : repli DoesPackageExist. Valide en jeu le 2026-10-03 (CHG-104). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
19 KiB
PowerShell
284 lines
19 KiB
PowerShell
<#
|
|
.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
|