<# .SYNOPSIS Cuit un mod d'assets et l'assemble en archive installable (lot H3b, Docs/Migration/16_H3_Mods_Paquets.md). .DESCRIPTION Sources : ModSources\\ (.uplugin, Content\, Module\ ; voir Tools/ModSource.ps1). 1. controles : plugin et module (MODULE_UNIQUEID = ), version de reference Releases\\Windows, aucun autre plugin dans Mods\ ; 1b. -Retarget bake|rigs : recopie les clips Quaternius sur Bridget et Michael (Tools/RetargetQuaternius.py dans UnrealEditor.exe, Slate requis). 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\, 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\ dans tous les cas ; 3. controle du conteneur : chaque paquet est sous Mods// (aucun asset du jeu embarque) ; 4. assemblage \-for-LifePlay-\LifePlay\{Mods\, Content\Modules\} + notice bilingue, archive .zip testee (7-Zip) ; un assemblage precedent du meme nom est deplace (precedent_), jamais supprime ; 5. -Install : 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 -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 = '', [switch] $RetargetOnly, [string] $Target = '', [switch] $Open ) $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' function Invoke-AnimToGenesis([string] $ProjectRoot, [string] $Mode) { $running = @(Get-Process UnrealEditor, UnrealEditor-Cmd -ErrorAction SilentlyContinue) if ($running) { throw "UnrealEditor est ouvert (pid $($running[0].Id)) : le fermer avant la conversion" } $stepFile = Join-Path $ProjectRoot 'Saved\anim_to_genesis_step.txt' New-Item -ItemType Directory -Force (Split-Path $stepFile) | Out-Null [IO.File]::WriteAllText($stepFile, ($Mode.Trim().ToLower() + "`r`n"), (New-Object Text.UTF8Encoding $false)) $editor = Join-Path $UE 'Binaries\Win64\UnrealEditor.exe' $project = Join-Path $ProjectRoot 'LifePlay.uproject' $script = Join-Path $ProjectRoot 'Tools\RetargetQuaternius.py' $logDir = Join-Path $ProjectRoot 'Saved\Logs' $success = if ($Mode -eq 'rigs') { 'RETARGET rigs prets' } else { 'RETARGET bridget \d+ michael \d+ deplaces \d+' } $before = @{} Get-ChildItem $logDir -Filter 'LifePlay*.log' -ErrorAction SilentlyContinue | ForEach-Object { $before[$_.FullName] = $_.Length } Write-Host "Conversion des clips Quaternius vers Bridget et Michael ($Mode)" $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) { $offset = 0 if ($before.ContainsKey($file.FullName)) { $offset = $before[$file.FullName] } if ($file.Length -le $offset) { continue } try { $stream = [IO.File]::Open($file.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite) } catch [System.IO.IOException] { continue } try { $stream.Seek($offset, [IO.SeekOrigin]::Begin) | Out-Null $reader = New-Object IO.StreamReader($stream) $text = $reader.ReadToEnd() } finally { $stream.Close() } $ok = [regex]::Match($text, $success) if ($ok.Success) { $hit = $ok.Value } $bad = [regex]::Match($text, 'chaine impossible[^\r\n]*|RETARGET deplacement rate[^\r\n]*|etape inconnue[^\r\n]*|LogPython: Error:[^\r\n]*') if ($bad.Success) { $failure = $bad.Value.Trim() } } 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" } $quiet = (Get-Date).AddMinutes(2) while ((Get-Date) -lt $quiet -and (Get-Process -Id $proc.Id -ErrorAction SilentlyContinue)) { 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 } 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//. $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