LifePlay-Revival/Source/LifePlay/LPSaveRelaunch.cpp
edujiro5972 8084622450 LifePlay UE 5.8 : état de développement au 2026-10-19
Tickets : REP1-T4

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 22:57:42 +02:00

396 lines
12 KiB
C++

// Lot REL-SAV (REL-SAV-SPEC) : dossier de sauvegarde du jeu empaquete, %LOCALAPPDATA%\LifePlay-Revival par defaut.
//
// Le moteur calcule Saved une seule fois, a partir de la seule ligne de commande (UE 5.8 : Paths.cpp:451-471, 485-494) :
// sans -UserDir, un Shipping ecrit dans %LOCALAPPDATA%\<nom du projet>\Saved. Le nom du projet (LifePlay) ne peut pas
// changer, et UE_SAVED_DIR_OVERRIDE est sans effet avec le moteur Launcher (Core precompile ; REL-SAV-DIAG).
//
// Garde : un objet statique de ce fichier s'execute a l'initialisation statique de l'exe, avant WinMain, donc avant que
// le moteur n'ecrive quoi que ce soit (journal, .ini, Saved). Si le jeu doit changer de dossier, il se relance avec les
// arguments d'origine recopies tels quels, plus -UserDir="<cible>", attend la fin de l'enfant et sort avec son code.
// - Compile seulement pour un jeu Windows monolithique hors editeur (jamais l'editeur, les commandlets ni -game).
// - Actif seulement dans un paquet (<projet>\Content\Paks\LifePlay-Windows.pak present).
// - Shipping : toujours ; Development et DebugGame : seulement si LPSaveLocation.ini existe (outils de debogage qui
// lisent Saved a cote de l'exe).
// - Inactif avec -LPNoSaveRelaunch, avec un -UserDir deja donne, ou si LP_SAVE_RELAUNCHED est posee (deux verrous :
// une seule relance possible).
// - Cible : SaveDir de <projet>\LPSaveLocation.ini, section [SaveLocation] (variables %...% developpees, chemin absolu
// seulement), sinon %LOCALAPPDATA%\LifePlay-Revival. Le moteur y range Saved\{SaveGames,Config,Logs...}.
// - Tout echec (dossier, relance) : demarrage normal, sans relance.
// Chemin statique : API Win32 et tampons wchar_t seulement (GMalloc, FString, FPaths, GConfig, UE_LOG pas encore prets).
#include "LPSaveRelaunch.h"
#include "LifePlay.h"
#include "HAL/PlatformMisc.h"
#include "Misc/Paths.h"
#if PLATFORM_WINDOWS && IS_MONOLITHIC && !WITH_EDITOR && defined(UE_GAME) && UE_GAME
#define LP_SAVE_RELAUNCH_ENABLED 1
#else
#define LP_SAVE_RELAUNCH_ENABLED 0
#endif
#if LP_SAVE_RELAUNCH_ENABLED
#include "Windows/WindowsHWrapper.h" // windows.h puis PostWindowsApi.h (macros conflictuelles retirees) avant ShlObj.h
#include "Windows/AllowWindowsPlatformTypes.h"
#include <shlobj.h>
#include "Windows/HideWindowsPlatformTypes.h"
namespace LPSaveRelaunchPrivate
{
// Verdict de la garde, lu par LogStartupState. Valeurs constantes : initialisees avant tout code dynamique.
enum EState : int
{
StateNotRun = 0,
StateNotPackaged,
StateDisabled,
StateUserDirGiven,
StateDevelopmentWithoutIni,
StateCreateDirFailed,
StateRelaunchFailed,
};
constexpr DWORD BufferLen = 32768; // longueur maximale d'une ligne de commande Windows
const wchar_t* const RelaunchedVar = L"LP_SAVE_RELAUNCHED";
int GState = StateNotRun;
wchar_t GExe[BufferLen];
wchar_t GProjectDir[BufferLen];
wchar_t GIniPath[BufferLen];
wchar_t GRaw[BufferLen];
wchar_t GTarget[BufferLen];
wchar_t GCommandLine[BufferLen];
DWORD Len(const wchar_t* S)
{
DWORD N = 0;
while (S[N])
{
++N;
}
return N;
}
// Ajoute Src a Dst (capacite BufferLen) ; false si la place manque.
bool Append(wchar_t* Dst, const wchar_t* Src)
{
DWORD N = Len(Dst);
for (DWORD I = 0; Src[I]; ++I)
{
if (N + 1 >= BufferLen)
{
return false;
}
Dst[N++] = Src[I];
}
Dst[N] = 0;
return true;
}
wchar_t Lower(wchar_t C)
{
return (C >= L'A' && C <= L'Z') ? wchar_t(C - L'A' + L'a') : C;
}
// Mot (minuscules) present dans Line, sans casse, precede de Before (ou en debut si AtStart) et, si
// bWholeWord, suivi d'une fin, d'un blanc ou d'un guillemet.
bool HasToken(const wchar_t* Line, const wchar_t* Word, const wchar_t* Before, bool bWholeWord)
{
const DWORD WordLen = Len(Word);
for (DWORD Pos = 0; Line[Pos]; ++Pos)
{
DWORD I = 0;
while (I < WordLen && Line[Pos + I] && Lower(Line[Pos + I]) == Word[I])
{
++I;
}
if (I != WordLen)
{
continue;
}
bool bBeforeOk = (Pos == 0);
for (DWORD B = 0; !bBeforeOk && Before[B]; ++B)
{
bBeforeOk = (Line[Pos - 1] == Before[B]);
}
const wchar_t After = Line[Pos + WordLen];
const bool bAfterOk = !bWholeWord || After == 0 || After == L' ' || After == L'\t' || After == L'"';
if (bBeforeOk && bAfterOk)
{
return true;
}
}
return false;
}
bool FileExists(const wchar_t* Path)
{
const DWORD Attr = GetFileAttributesW(Path);
return Attr != INVALID_FILE_ATTRIBUTES && !(Attr & FILE_ATTRIBUTE_DIRECTORY);
}
bool DirExists(const wchar_t* Path)
{
const DWORD Attr = GetFileAttributesW(Path);
return Attr != INVALID_FILE_ATTRIBUTES && (Attr & FILE_ATTRIBUTE_DIRECTORY);
}
// Coupe Path apres son dernier separateur (le separateur compris) ; false s'il n'y en a pas.
bool CutLastComponent(wchar_t* Path)
{
for (DWORD I = Len(Path); I > 0; --I)
{
if (Path[I - 1] == L'\\' || Path[I - 1] == L'/')
{
Path[I - 1] = 0;
return true;
}
}
return false;
}
// Arguments apres argv[0], recopies tels quels (regle Windows : argv[0] entre guillemets ou jusqu'au premier blanc).
const wchar_t* SkipProgramName(const wchar_t* Line)
{
const wchar_t* P = Line;
if (*P == L'"')
{
++P;
while (*P && *P != L'"')
{
++P;
}
if (*P)
{
++P;
}
}
else
{
while (*P && *P != L' ' && *P != L'\t')
{
++P;
}
}
while (*P == L' ' || *P == L'\t')
{
++P;
}
return P;
}
bool IsAbsolute(const wchar_t* P)
{
const bool bDrive = ((P[0] >= L'A' && P[0] <= L'Z') || (P[0] >= L'a' && P[0] <= L'z')) && P[1] == L':'
&& (P[2] == L'\\' || P[2] == L'/');
const bool bUnc = P[0] == L'\\' && P[1] == L'\\';
return bDrive || bUnc;
}
// GTarget = SaveDir de l'ini (blancs et guillemets de bord retires, variables developpees) s'il est absolu ; sinon
// %LOCALAPPDATA%\LifePlay-Revival. Source dans OutSource ; false si aucun dossier n'a pu etre calcule.
bool ComputeTarget(bool bIniExists, const wchar_t*& OutSource)
{
GTarget[0] = 0;
if (bIniExists)
{
GetPrivateProfileStringW(L"SaveLocation", L"SaveDir", L"", GRaw, BufferLen, GIniPath);
wchar_t* Start = GRaw;
while (*Start == L' ' || *Start == L'\t' || *Start == L'"')
{
++Start;
}
DWORD End = Len(Start);
while (End > 0 && (Start[End - 1] == L' ' || Start[End - 1] == L'\t' || Start[End - 1] == L'"'))
{
Start[--End] = 0;
}
if (*Start)
{
const DWORD Needed = ExpandEnvironmentStringsW(Start, GTarget, BufferLen);
bool bHasQuote = false; // un " fermerait -UserDir="..." (VAL R1) ; interdit dans un chemin Windows
for (DWORD I = 0; !bHasQuote && I < BufferLen && GTarget[I]; ++I)
{
bHasQuote = (GTarget[I] == L'"');
}
if (Needed == 0 || Needed > BufferLen || bHasQuote || !IsAbsolute(GTarget))
{
GTarget[0] = 0;
}
}
}
if (GTarget[0])
{
OutSource = L"ini";
}
else
{
PWSTR LocalAppData = nullptr;
const bool bOk = SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &LocalAppData)) && LocalAppData
&& Append(GTarget, LocalAppData) && Append(GTarget, L"\\LifePlay-Revival");
if (LocalAppData)
{
CoTaskMemFree(LocalAppData);
}
if (!bOk)
{
return false;
}
OutSource = L"defaut";
}
// Pas de separateur final : un \" final serait lu comme un guillemet echappe. Racine X: -> X:\.
DWORD N = Len(GTarget);
while (N > 0 && (GTarget[N - 1] == L'\\' || GTarget[N - 1] == L'/'))
{
GTarget[--N] = 0;
}
if (N == 2 && GTarget[1] == L':')
{
return Append(GTarget, L"\\.");
}
return N > 0;
}
void Run()
{
// 1. Exe reel (Binaries\Win64\LifePlay-Win64-<Config>.exe, pas le bootstrap) et dossier du projet (..\..).
const DWORD ExeLen = GetModuleFileNameW(nullptr, GExe, BufferLen);
if (ExeLen == 0 || ExeLen >= BufferLen)
{
GState = StateNotPackaged;
return;
}
GProjectDir[0] = 0;
Append(GProjectDir, GExe);
if (!CutLastComponent(GProjectDir) || !CutLastComponent(GProjectDir) || !CutLastComponent(GProjectDir))
{
GState = StateNotPackaged;
return;
}
GRaw[0] = 0;
if (!Append(GRaw, GProjectDir) || !Append(GRaw, L"\\Content\\Paks\\LifePlay-Windows.pak") || !FileExists(GRaw))
{
GState = StateNotPackaged;
return;
}
// 2. Desactivation explicite ; 3. verrous anti-boucle.
const wchar_t* const Tail = SkipProgramName(GetCommandLineW());
if (HasToken(Tail, L"-lpnosaverelaunch", L" \t\"", true))
{
GState = StateDisabled;
return;
}
if (HasToken(Tail, L"userdir=", L"-/", false) || GetEnvironmentVariableW(RelaunchedVar, GRaw, BufferLen) > 0)
{
GState = StateUserDirGiven;
return;
}
// 4. Configuration : Shipping toujours, sinon seulement avec LPSaveLocation.ini.
GIniPath[0] = 0;
const bool bIniExists = Append(GIniPath, GProjectDir) && Append(GIniPath, L"\\LPSaveLocation.ini") && FileExists(GIniPath);
#if !UE_BUILD_SHIPPING
if (!bIniExists)
{
GState = StateDevelopmentWithoutIni;
return;
}
#endif
// Cible, creee si besoin.
const wchar_t* Source = L"defaut";
if (!ComputeTarget(bIniExists, Source))
{
GState = StateCreateDirFailed;
return;
}
const int Created = SHCreateDirectoryExW(nullptr, GTarget, nullptr);
if ((Created != ERROR_SUCCESS && Created != ERROR_ALREADY_EXISTS && Created != ERROR_FILE_EXISTS) || !DirExists(GTarget))
{
OutputDebugStringW(L"LifePlay : dossier de sauvegarde impossible a creer, demarrage sans relance\n");
GState = StateCreateDirFailed;
return;
}
// Relance : "<exe>" <arguments d'origine tels quels> -UserDir="<cible>".
GCommandLine[0] = 0;
const bool bLineOk = Append(GCommandLine, L"\"") && Append(GCommandLine, GExe) && Append(GCommandLine, L"\" ")
&& Append(GCommandLine, Tail) && (Tail[0] == 0 || Append(GCommandLine, L" "))
&& Append(GCommandLine, L"-UserDir=\"") && Append(GCommandLine, GTarget) && Append(GCommandLine, L"\"");
if (!bLineOk)
{
GState = StateRelaunchFailed;
return;
}
STARTUPINFOW ParentInfo = {};
ParentInfo.cb = sizeof(ParentInfo);
GetStartupInfoW(&ParentInfo);
STARTUPINFOW Info = {};
Info.cb = sizeof(Info);
Info.dwFlags = STARTF_USESHOWWINDOW;
Info.wShowWindow = (ParentInfo.dwFlags & STARTF_USESHOWWINDOW) ? ParentInfo.wShowWindow : WORD(SW_SHOWDEFAULT);
PROCESS_INFORMATION Process = {};
SetEnvironmentVariableW(RelaunchedVar, Source);
if (!CreateProcessW(GExe, GCommandLine, nullptr, nullptr, /*bInheritHandles*/ 0, 0, nullptr, nullptr, &Info, &Process))
{
SetEnvironmentVariableW(RelaunchedVar, nullptr);
OutputDebugStringW(L"LifePlay : relance avec -UserDir impossible, demarrage sans relance\n");
GState = StateRelaunchFailed;
return;
}
// Le parent attend l'enfant et sort avec son code, sans destructeurs statiques du moteur (TerminateProcess).
CloseHandle(Process.hThread);
WaitForSingleObject(Process.hProcess, INFINITE);
DWORD ExitCode = 1;
GetExitCodeProcess(Process.hProcess, &ExitCode);
CloseHandle(Process.hProcess);
TerminateProcess(GetCurrentProcess(), ExitCode);
}
struct FGuard
{
FGuard()
{
Run();
}
};
FGuard GGuard;
}
#endif // LP_SAVE_RELAUNCH_ENABLED
void LPSaveRelaunch::LogStartupState()
{
FString Relaunched = TEXT("non");
FString Source = TEXT("inactif");
FString Reason = TEXT("-");
#if LP_SAVE_RELAUNCH_ENABLED
using namespace LPSaveRelaunchPrivate;
switch (GState)
{
case StateNotPackaged: Reason = TEXT("non-empaquete"); break;
case StateDisabled: Reason = TEXT("desactive"); break;
case StateDevelopmentWithoutIni: Reason = TEXT("Development-sans-ini"); break;
case StateCreateDirFailed: Reason = TEXT("echec-creation"); break;
case StateRelaunchFailed: Reason = TEXT("echec-relance"); break;
case StateUserDirGiven:
{
// Enfant relance : LP_SAVE_RELAUNCHED porte la source (ini ou defaut) ; sinon -UserDir donne au lancement.
const FString Var = FPlatformMisc::GetEnvironmentVariable(RelaunchedVar);
Relaunched = Var.IsEmpty() ? FString(TEXT("non")) : FString(TEXT("oui"));
Source = Var.IsEmpty() ? FString(TEXT("-UserDir")) : Var;
break;
}
default: Reason = TEXT("non-execute"); break;
}
#elif WITH_EDITOR
Reason = TEXT("editeur");
#else
Reason = TEXT("non-compile");
#endif
UE_LOG(LogLifePlay, Display, TEXT("Dossier de sauvegarde : %s (relance=%s, source=%s, motif=%s)"),
*FPaths::ConvertRelativePathToFull(FPaths::ProjectUserDir()), *Relaunched, *Source, *Reason);
}