12 KiB
Fiche REL-SAV-T2 — Niveau N3 — Modèle prévu opus | utilisé claude-opus-5-5 (session REL, sans sous-agent) — Agent session REL — Skills lp-lot, lp-paths
REL-SAV-SPEC : garde B+ (relance avec -UserDir), choix « B+ seul », preuve attendue en jeu (2026-10-13)
Base : REL-SAV-DIAG.md (accepté par Big Boss le 2026-10-13). Décisions de l'utilisateur : U1-U4 (REL-SAV-TACHES.md), R0 (aucune reprise des anciennes sauvegardes), B+ avant l'alpha (échéance 2026-10-18). GO de Big Boss : (a) cette SPEC, (b) la preuve, (c) l'IMPL sans compiler, après acceptation de cette SPEC.
Lecture seule jusqu'ici : rien n'est écrit dans Source\, Config\ ni Content\.
0. « B+ seul » ou « B + B+ » : recommandation B+ seul
| Critère | B+ seul | B + B+ |
|---|---|---|
| Points d'entrée | un seul : LifePlay.exe (bootstrap), l'exe Binaries\Win64 lancé directement et tout raccourci passent par la garde |
deux : le lanceur et LifePlay.exe, qui font la même chose |
| Rôle du lanceur avec R0 | — | aucun : sans copie de sauvegardes, il ne ferait que passer -UserDir, ce que la garde fait déjà |
| Coût | C++ seul (≈ 150 lignes, une fonction et un .ini lu par l'API Win32) |
le même, plus un lanceur, l'étape d'empaquetage et le README |
| Risque | une seconde création de processus au démarrage (< 1 s, estimé) ; le parent reste en attente (quelques Mo, moteur non initialisé) | le même, plus deux mécanismes à tenir cohérents |
B n'apporterait rien de plus que B+ sans reprise. On le rouvrirait seulement si l'essai de B+ échouait. Aucun lanceur n'est donc livré.
1. Où accrocher la garde : un initialiseur statique, avant WinMain
Constat (établi) :
StartupModuledu moduleLifePlay(Source\LifePlay\LifePlay.cpp:13-16) arrive trop tard : la pré-initialisation a déjà fixéProjectSavedDir()(mis en cache :Paths.cpp:485-494), ouvert le journal et lu la configuration.- Aucun point d'accroche du moteur ne précède
FCommandLine::Set.LaunchWindowsStartuprelit::GetCommandLineW()(E\Source\Runtime\Launch\Private\Windows\LaunchWindows.cpp:198-200).
Choix : un objet statique global dans le module LifePlay. Son constructeur s'exécute pendant l'initialisation statique de l'exe, avant WinMain. Le moteur n'a alors rien écrit : ni journal, ni .ini, ni Saved.
- En monolithique, le module est relié comme objet, pas comme bibliothèque :
LifePlay-Win64-Shipping.exe.rspciteLifePlay/Module.LifePlay.cpp.obj(établi). L'initialiseur ne peut donc pas être écarté par l'éditeur de liens.IMPLEMENT_PRIMARY_GAME_MODULE, dans le même module, repose déjà sur une inscription statique. - Fichier :
Source\LifePlay\LPSaveRelaunch.cpp, nouveau. Pas d'en-tête public. La seule fonction exportée estLPSaveRelaunch::LogStartupState(), appelée parStartupModule(§ 6).
Contraintes du code d'initialisation statique (ordre d'initialisation non garanti entre unités de compilation) :
- API Win32 et tampons
wchar_tseulement. Pas deFString,FPaths,FParse,GConfig,UE_LOGniFMemory:GMallocn'est pas encore créé. - Aucune exception ; pas de
std::qui alloue. - Tampons fixes de
32768caractères (taille maximale d'une ligne de commande Windows) : statiques, ou alloués parHeapAlloc(GetProcessHeap(), …).
2. Quand la garde agit (jamais dans l'éditeur)
À la compilation, le code n'est compilé que sous #if PLATFORM_WINDOWS && IS_MONOLITHIC && !WITH_EDITOR && UE_GAME (UE_GAME=1 pour une cible Game : TargetRules.cs:3479). L'éditeur, les commandlets et UnrealEditor.exe -game en sont exclus d'office.
À l'exécution, conditions dans l'ordre. Dès que l'une échoue, on sort sans rien faire et le démarrage reste inchangé :
- Le jeu tourne depuis un paquet :
<dossier de l'exe>\..\..\Content\Paks\LifePlay-Windows.pakexiste (GetFileAttributesW). Établi : présent dans le paquetShipping_V2.0, absent de la copie de travail (Content\Paksinexistant). Une build autonome non cuite lancée depuis le projet n'est donc pas touchée. - Pas de désactivation explicite : la ligne de commande ne contient pas
-LPNoSaveRelaunch. - Pas de boucle :
- la ligne de commande ne contient pas
-UserDir=(comparaison sans casse, précédée d'un espace ou d'un guillemet : cas de la relance et desLancer_*_essai.bat) ; - la variable d'environnement
LP_SAVE_RELAUNCHEDest absente. Le parent la pose avantCreateProcessW, l'enfant en hérite. Deux verrous indépendants : il ne peut y avoir qu'une relance.
- la ligne de commande ne contient pas
- Configuration :
- Shipping (
UE_BUILD_SHIPPING) : toujours actif. - Development empaqueté : actif seulement si
LPSaveLocation.iniexiste (adhésion volontaire). Ainsi les outils de débogage qui lisent<build>\Windows\LifePlay\Savedrestent valides (CollectDebugArtifacts.ps1:18-20,Tools\debug\*), et l'essai en build commun Development n'a besoin que de poser le fichier. - DebugGame : comme Development.
- Shipping (
3. LPSaveLocation.ini et le dossier cible
- Emplacement :
<dossier de l'exe>\..\..\LPSaveLocation.ini, c'est-à-dire<installation>\Windows\LifePlay\LPSaveLocation.ini, ouFPaths::ProjectDir()côté moteur (décision U2). Il n'est pas livré : s'il est absent, on prend le dossier par défaut. - Format, lu par
GetPrivateProfileStringWet compatible avecFConfigFilepour le futur menu (U4) :
[SaveLocation]
SaveDir=
- Calcul de la cible :
SaveDirest lu, débarrassé de ses espaces et guillemets de bord, puis passé àExpandEnvironmentStringsW(%LOCALAPPDATA%permis).- Si le résultat est vide, ou n'est pas un chemin absolu (
X:\…ou\\serveur\…), on prend le défaut :SHGetKnownFolderPath(FOLDERID_LocalAppData)+\LifePlay-Revival. C'est la même base que le moteur (WindowsPlatformProcess.cpp:1467; le cas « intégrité basse » est ignoré, comme dans la décision U3). - On retire les
\ou/de fin : un\"final serait lu comme un guillemet échappé. - On crée le dossier s'il manque (
SHCreateDirectoryExW). En cas d'échec, pas de relance (OutputDebugStringWseulement, décision U3). Le jeu démarre alors comme aujourd'hui, et la ligne de § 6 le dit.
- Le moteur range ensuite
Savedsous la cible (Paths.cpp:453-457, puis:86-115) : par défaut%LOCALAPPDATA%\LifePlay-Revival\Saved\{SaveGames,Config,Logs,…}.
4. Relance et transmission des arguments d'origine
- Lire
GetModuleFileNameW(nullptr). C'est l'exe réelBinaries\Win64\LifePlay-Win64-<Config>.exe, et non le bootstrap. - Lire
GetCommandLineW(), sauterargv[0]selon la règle Windows (s'il commence par", jusqu'au"suivant, sinon jusqu'au premier blanc), puis les blancs. Le reste (queue brute) est recopié tel quel, sans redécoupage ni requote. Tous les arguments sont ainsi gardés, dans leur ordre et avec leurs guillemets :..\..\..\LifePlay\LifePlay.uprojectajouté par le bootstrap,-ScriptStackOnWarnings,-LPPersonsLog, etc. - Nouvelle ligne :
"<exe>" <queue brute> -UserDir="<cible>". SetEnvironmentVariableW(L"LP_SAVE_RELAUNCHED", L"1"), puisCreateProcessW(exe, ligne, …, bInheritHandles = FALSE, 0, nullptr /*environnement hérité*/, nullptr /*dossier courant hérité*/, &si, &pi).siest initialisé avecGetStartupInfoW, pour transmettrewShowWindow.- Le parent attend l'enfant (
WaitForSingleObject(pi.hProcess, INFINITE)), lit son code de sortie (GetExitCodeProcess), puis se termine parTerminateProcess(GetCurrentProcess(), code). Raisons :- le bootstrap et les scripts qui attendent
LifePlay.exe(Tools\debug\LifePlay_debug_ui.ps1) gardent le même comportement et le même code de sortie (estimé : le comportement du bootstrap n'est pas lisible, ses sources manquent dans l'installation Launcher) ; TerminateProcessplutôt qu'ExitProcessévite d'appeler les destructeurs des objets statiques du moteur déjà construits.
- le bootstrap et les scripts qui attendent
- Si
CreateProcessWéchoue : on retireLP_SAVE_RELAUNCHED, on n'attend rien, et le démarrage normal continue (ancien comportement, signalé en § 6).
5. Ce qui reste hors de cette SPEC
- La ligne du menu Misc (U4 : écrire
LPSaveLocation.ini, bouton « Default », choix du dossier) : fiche suivante, après l'essai de B+. Le format de § 3 est fixé ici pour elle. - README (nouveau dossier,
LPSaveLocation.ini, aucune reprise automatique : R0),Tools\TracesRacines.ps1:90(ajouterLifePlay-Revival),lp-buildl. 71-72 etlp-validatel. 96-100 : DOC du lot.
6. Trace dans le journal (preuve)
StartupModule appelle LPSaveRelaunch::LogStartupState(), qui écrit une ligne Display dans LogLifePlay :
Dossier de sauvegarde : <FPaths::ProjectUserDir()> (relance=<oui|non>, source=<ini|defaut|-UserDir|inactif>, motif=<…>).
relance=ouisiLP_SAVE_RELAUNCHEDvaut1.motif: raison de l'inaction (editeur,non-empaquete,desactive,Development-sans-ini,echec-creation,echec-relance).
La garde statique mémorise son verdict dans une variable statique du même fichier : un enum et des wchar_t, sans FString.
7. Preuve attendue en jeu (build commun COMMUN-4, Development ; Shipping en fin d'essai si Big Boss l'accorde)
Préparation, sans jeton UE (inventaire seulement) :
- relevé
Get-ChildItem -Recurse+Get-FileHash MD5de<build>\Windows\LifePlay\Saved(ancien dossier en Development) ; - même relevé pour
%LOCALAPPDATA%\LifePlay(ancien dossier en Shipping, sauvegardes réelles de l'utilisateur : lecture seule).
| Cas | Montage | Attendu |
|---|---|---|
| E1 | Development, sans LPSaveLocation.ini |
comportement inchangé : Saved à côté de l'exe ; une seule instance de LifePlay-Win64-Development.exe ; journal source=inactif, motif=Development-sans-ini |
| E2 | Development, LPSaveLocation.ini avec SaveDir=D:\Game\LifePlay_Backup_5.5\REL\essai_userdir |
deux processus le temps du jeu : le parent attend, l'enfant a -UserDir="…\essai_userdir" (Get-CimInstance Win32_Process, CommandLine) ; …\essai_userdir\Saved\{Logs,Config,SaveGames} créé ; journal relance=oui, source=ini ; relevé de l'ancien Saved identique avant et après, journal compris (attendu : 0 fichier créé ni modifié, car la garde agit avant toute écriture du moteur ; tout écart, même Logs\LifePlay.log, est mesuré et rapporté) ; en partie : nouvelle partie, sauvegarde, Continuer, mods |
| E3 | E2 + lancement avec -ScriptStackOnWarnings -LPPersonsLog |
les deux arguments présents dans la ligne de l'enfant et dans LogInit: Command Line: de son journal, dans l'ordre d'origine |
| E4 | E2 + lancement avec un -UserDir= explicite |
aucune relance (une instance), source=-UserDir |
| E5 | SaveDir=relatif\x, puis SaveDir= vide |
dossier par défaut %LOCALAPPDATA%\LifePlay-Revival ; journal source=defaut |
| E6 | -LPNoSaveRelaunch |
une instance, motif=desactive |
| E7 | Fermeture du jeu par le menu | code de sortie du bootstrap = code de l'enfant ($LASTEXITCODE ou Start-Process -Wait -PassThru) ; aucune instance restante |
| E8 (Shipping, sur accord) | Shipping sans .ini |
%LOCALAPPDATA%\LifePlay-Revival\Saved créé ; relevé de %LOCALAPPDATA%\LifePlay identique ; partie neuve et Continuer dans le nouveau dossier |
Verdict de l'essai : PASS si E1 à E7 sont conformes et si l'ancien dossier n'est pas modifié. Un journal de pré-initialisation écrit dans l'ancien dossier serait un écart à rapporter : il n'est pas attendu, puisque la garde précède WinMain.
8. IMPL (c), une fois cette SPEC acceptée
- Instantané :
Tools\Snapshot.ps1 -Lot REL-SAV -Path Source\LifePlay\LifePlay.cpp, Source\LifePlay\LPSaveRelaunch.cpp(le second est absent :Existed=False). - Fichiers :
LPSaveRelaunch.cpp(nouveau) etLifePlay.cpp(1#include+ 1 appel dansStartupModule, aprèsFLPModGuard::Run()).LifePlay.Build.cs: aucune dépendance nouvelle attendue (Shell32,Ole32déjà reliées par le moteur ; estimé, à confirmer à la compilation de COMMUN-4). En-têtes Windows entreWindows/AllowWindowsPlatformTypes.hetHideWindowsPlatformTypes.h. - Sans compiler. VAL statique par un
lp-validatordistinct : relecture du diff ligne à ligne contre §§ 1-6 ; garde de compilation ; aucune API UE dans le chemin statique ;\"final ; deux verrous anti-boucle ; queue brute non retouchée ;TerminateProcessseulement après l'attente.