LifePlay-Revival/Docs/Migration/etat/VQ/VQ2/VQ2-SPEC.md
edujiro5972 0f1a61c9fe VQ : qualite video (sous-systeme LPVideoQuality, onglet Reglages, DefaultScalability), lots VQ0 a VQ5 et correctifs (CHG-237 a 242)
LifePlay.uproject : SupportedTargetPlatforms Win64 sur LPAnimCore, origine non revendiquee, range ici.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:40:47 +02:00

336 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# VQ2-SPEC — Spécification `ULPVideoQualitySubsystem`
**Fiche :** VQ2-T1
**Rôle :** A-ARCH
**Modèle attendu :** claude-sonnet-5-5-high (M2)
**Date :** 07 octobre 2026
**Workspace :** `d:\Game\LifePlay-master 5.5`
**Statut :** Spécification — prête pour implémentation (VQ2-T2) et branchement onglet (VQ2-T3)
**Dépend de :** `VQ0-CART.md` (§5–§7), `VQ1-SPEC.md` (§4–§6, `Config/DefaultScalability.ini`)
**Patron :** `Source/LifePlay/LPColorGradeSubsystem.h/.cpp` (CHG-161 / CHG-163)
---
## 1. Objectif
Ajouter, dans l'onglet **Video** de `SettingsScrollBox`, un bloc de six lignes C++ (`LPVideo*`) qui pilote les **4 groupes Scalability** définis en VQ1 (ombres, textures, effets, anti-aliasing) par un curseur **Overall** (préréglage Low → Epic) et quatre curseurs individuels. Le bloc est inséré **avant** `LPColorBrightness` (VQ0-CART §7).
Hors périmètre VQ2 : calcul VRAM du bouton Recommended (VQ3), tout changement de `DefaultScalability.ini` / `DefaultEngine.ini`, tout changement Blueprint, GI / VSM / méthode d'AA / reflets (verrous L7c, VQ1 §2.2).
## 2. Fichiers
| Fichier | Action | Fiche |
|---|---|---|
| `Source/LifePlay/LPVideoQualitySubsystem.h` | Nouveau | VQ2-T2 |
| `Source/LifePlay/LPVideoQualitySubsystem.cpp` | Nouveau | VQ2-T2 |
| `Source/LifePlay/LPSettingsSubsystem.cpp` (`CategoryOf`, `ApplyRows`) | Modifié (§9) | VQ2-T3 |
| `Source/LifePlay/LifePlay.Build.cs` | **Inchangé** : `Engine` (Scalability), `Slate`, `SlateCore`, `UMG` déjà présents | — |
| `Saved/Config/Windows/LPVideoQuality.ini` | Créé à l'exécution | — |
Includes attendus (.cpp) : `Scalability.h`, `Components/Button.h`, `Components/ScrollBox.h`, `Components/ScrollBoxSlot.h`, `Components/HorizontalBox.h`, `Components/HorizontalBoxSlot.h`, `Components/SizeBox.h`, `Components/Slider.h`, `Components/TextBlock.h`, `Blueprint/UserWidget.h`, `HAL/IConsoleManager.h`, `Misc/ConfigCacheIni.h`, `Misc/Paths.h`, `LifePlay.h` (`LogLifePlay`).
## 3. Types
### 3.1 `FLPVideoLevels`
```cpp
USTRUCT()
struct FLPVideoLevels
{
GENERATED_BODY()
UPROPERTY() int32 Overall = 2; // 0..3, ou -1 = Custom
UPROPERTY() int32 Shadows = 2; // 0..3 -> sg.ShadowQuality
UPROPERTY() int32 Textures = 2; // 0..3 -> sg.TextureQuality
UPROPERTY() int32 Effects = 2; // 0..3 -> sg.EffectsQuality
UPROPERTY() int32 AA = 2; // 0..3 -> sg.AntiAliasingQuality
static FLPVideoLevels FromOverall(int32 Level); // 4 groupes = Level (clampé 0..3), Overall = Level
void Normalize(); // clamp 0..3 des 4 groupes ; Overall := (4 groupes égaux ? valeur : -1)
bool operator==(const FLPVideoLevels& Other) const;
};
```
Les valeurs par défaut du struct ne servent que de repli ; la première exécution dérive les niveaux de l'état Scalability courant (§6.2).
### 3.2 Invariant
Après `Normalize()` : `Overall == -1` **si et seulement si** les 4 groupes ne sont pas tous égaux. Si `Overall >= 0`, les 4 groupes valent `Overall`. L'invariant est ré-imposé à chaque `Load()` : une valeur `Overall` incohérente lue dans l'INI est ignorée et recalculée.
### 3.3 Libellés de niveaux
| Niveau | Libellé (readout) |
|---|---|
| 0 | `Low` |
| 1 | `Med` |
| 2 | `High` |
| 3 | `Epic` |
| Overall = -1 | `Custom` |
## 4. Classe
```cpp
UCLASS()
class LIFEPLAY_API ULPVideoQualitySubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
// Point d'entrée unique de modification (UI, Recommended, VQ3).
void SetLevels(FLPVideoLevels NewLevels, bool bFromOverall);
FLPVideoLevels GetLevels() const { return Levels; }
void RefreshWidgets();
UFUNCTION() void OnOverallChanged(float Value);
UFUNCTION() void OnShadowsChanged(float Value);
UFUNCTION() void OnTexturesChanged(float Value);
UFUNCTION() void OnEffectsChanged(float Value);
UFUNCTION() void OnAAChanged(float Value);
UFUNCTION() void OnRecommendedClicked();
private:
enum ERow { Overall, Shadows, Textures, Effects, AA, NumRows };
struct FRowRefs { TWeakObjectPtr<USlider> Slider; TWeakObjectPtr<UTextBlock> Readout; };
bool Tick(float DeltaTime); // 0.5 s, comme ColorGrade
void EnsureControls();
void Load();
void Save() const;
void ApplyToScalability(); // §6
USlider* AddRow(...); // même signature que ColorGrade::AddRow (+ ERow)
UButton* AddButtonRow(...); // ligne Recommended
static FString LevelLabel(int32 Level);
FTSTicker::FDelegateHandle TickHandle;
FRowRefs Rows[NumRows];
TWeakObjectPtr<UButton> RecommendedButton;
TWeakObjectPtr<UWorld> LastAppliedWorld;
FLPVideoLevels Levels;
bool bRefreshing = false; // garde anti-réentrance
};
```
`Initialize` : `Load()` → `ApplyToScalability()` → enregistre le ticker 0.5 s (même patron que `ULPColorGradeSubsystem`). `Deinitialize` : retire le ticker.
## 5. API
### 5.1 `SetLevels(FLPVideoLevels NewLevels, bool bFromOverall)`
Ordre strict :
1. **Normaliser.**
- `bFromOverall == true` : `Overall` clampé 0..3 → `NewLevels = FromOverall(Overall)`. Si `Overall < 0`, la valeur est ignorée et on retombe sur le cas `false` (pas de fan-out).
- `bFromOverall == false` : clamp des 4 groupes ; `Overall` recalculé par `Normalize()` (égaux → valeur, sinon `-1`). Le champ `Overall` fourni est **ignoré**.
2. `Levels = NewLevels`.
3. **`ApplyToScalability()`** (§6).
4. **`RefreshWidgets()`** (§5.2).
5. **`Save()`** (§7).
Aucune étape ne doit dépendre de la présence des widgets : `SetLevels` est appelable menu fermé (Initialize, VQ3, console de debug).
### 5.2 `RefreshWidgets()`
Garde `bRefreshing` (set/reset autour du corps). Pour chaque `FRowRefs` valide :
| Ligne | Position du curseur | Readout |
|---|---|---|
| Overall | `Overall >= 0` ? `Overall` : `clamp(round(moyenne des 4 groupes), 0, 3)` | `LevelLabel(Overall)` ou **`Custom`** |
| Shadows / Textures / Effects / AA | valeur du groupe | `LevelLabel(valeur)` |
`USlider::SetValue` ne diffuse pas `OnValueChanged` (seul l'événement Slate utilisateur le fait) ; la garde `bRefreshing` protège néanmoins contre un changement de comportement. Les handlers `On*Changed` retournent immédiatement si `bRefreshing`.
### 5.3 Handlers
| Handler | Action |
|---|---|
| `OnOverallChanged(V)` | `Overall = clamp(RoundToInt(V),0,3)` → `SetLevels(FromOverall(Overall), true)` |
| `OnShadowsChanged(V)` | `L = Levels; L.Shadows = clamp(RoundToInt(V),0,3)` → `SetLevels(L, false)` |
| `OnTexturesChanged` / `OnEffectsChanged` / `OnAAChanged` | idem sur leur champ |
| `OnRecommendedClicked()` | Stub VQ2 : `SetLevels(FromOverall(2), true)` + `UE_LOG(... "Video : Recommended = stub (High) ; VRAM prevue en VQ3")`. VQ3 remplace uniquement le corps de cette fonction. |
### 5.4 `Load()` / `Save()` — voir §7.
### 5.5 `EnsureControls()` — voir §8.
## 6. Application Scalability
### 6.1 `ApplyToScalability()`
```cpp
Scalability::FQualityLevels Q = Scalability::GetQualityLevels(); // préserve les champs non gérés
Q.ShadowQuality = Levels.Shadows;
Q.TextureQuality = Levels.Textures;
Q.EffectsQuality = Levels.Effects;
Q.AntiAliasingQuality = Levels.AA;
Scalability::SetQualityLevels(Q, /*bForce=*/true);
```
Règles :
- **Seuls 4 champs sont écrits** (VQ1 §5). `ResolutionQuality`, `ViewDistanceQuality`, `PostProcessQuality`, `GlobalIlluminationQuality`, `ReflectionQuality`, `ShadingQuality`, `FoliageQuality`, etc. restent ceux de l'état courant. Aucun appel à `SetFromSingleQualityLevel`.
- Niveau 4 (Cinematic) non exposé : pas de valeur > 3 écrite.
- Pas d'appel à `Scalability::SaveState(GGameUserSettingsIni)` : la source de vérité est `LPVideoQuality.ini` (§7). `GameUserSettings.ini` peut contenir des `sg.*` périmés ; ils sont appliqués par le moteur **avant** `Initialize`, puis écrasés par `ApplyToScalability()`.
- **Vérification de relecture** (log `Display`, une ligne) : après `SetQualityLevels`, relire `Scalability::GetQualityLevels()` et comparer aux 4 niveaux ; en cas d'écart, `UE_LOG(Warning)` + repli (R1, §11) : pour chaque groupe divergent, `IConsoleManager::Get().FindConsoleVariable(TEXT("sg.<Groupe>"))->Set(Level, ECVF_SetByConsole)` puis un second `SetQualityLevels`.
### 6.2 Premier lancement (pas d'INI)
Si `LPVideoQuality.ini` n'existe pas : `Levels` = niveaux 4 groupes lus dans `Scalability::GetQualityLevels()` (clampés 0..3 ; un niveau 4/Cine est ramené à 3), puis `Normalize()` ; **aucun `Save()` à l'initialisation** (le fichier n'est écrit qu'au premier changement de l'utilisateur). Ainsi un joueur qui n'ouvre jamais l'onglet conserve exactement son comportement actuel.
### 6.3 Déclencheurs de ré-application (idempotents)
| # | Déclencheur | Raison |
|---|---|---|
| a | `Initialize` | Appliquer l'INI sur l'état moteur chargé |
| b | Premier `Tick` après changement de monde de jeu (`LastAppliedWorld != GetWorld()` et `IsGameWorld()`) | Le Blueprint `MainMenu` / `GameSettings` peut réappliquer ses valeurs `.sav` au chargement d'un niveau |
| c | À chaque injection réussie de `EnsureControls()` (nouvelle instance `MainMenu`) | Idem, à l'ouverture du menu |
Pas de ré-application continue à 0.5 s : cela écraserait les commandes console `sg.*` de debug.
## 7. Persistance — `Saved/Config/Windows/LPVideoQuality.ini`
Chemin : `FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("Config/Windows/LPVideoQuality.ini"))` (patron `LPColorGrade::IniPath`). Lecture/écriture via `FConfigFile` (`Read` → `SetString` → `Write`).
```ini
[LPVideoQuality]
Version=1
Overall=2
Shadows=2
Textures=2
Effects=2
AA=2
```
- `Load()` : lit les 5 clés (`FCString::Atoi`), clamp 0..3 (`Overall` : -1..3), **`Normalize()` recalcule `Overall`**. Une clé manquante conserve la valeur dérivée du §6.2 ; si **aucune** clé n'est présente, comportement §6.2. Log `Display` en français, style ColorGrade : `Video : relu Overall %d, Shadows %d, Textures %d, Effects %d, AA %d depuis %s`.
- `Save()` : écrit les 6 clés + log `... enregistres dans %s`. Appelé uniquement par `SetLevels`.
- `Version` réservé aux migrations futures ; une version > 1 est lue en mode best-effort (clés connues seulement).
## 8. `EnsureControls()` — injection UI
### 8.1 Recherche du `SettingsScrollBox`
Identique à `ULPColorGradeSubsystem::EnsureControls` : `TObjectIterator<UScrollBox>`, nom `SettingsScrollBox`, hors CDO/archétype, `GetTypedOuter<UUserWidget>()` valide, non CDO, `GetGameInstance() == GetGameInstance()`. Sortie silencieuse si introuvable.
### 8.2 Garde
Sortie si un enfant direct du scroll s'appelle `LPVideoOverall` (déjà injecté pour cette instance de menu). Une nouvelle instance `MainMenu` n'a pas ce widget → réinjection ; les `TWeakObjectPtr` précédents sont remplacés.
### 8.3 Lignes injectées (ordre d'affichage)
| # | Nom widget | Libellé | Type | Min–Max | Pas | Handler | Info-bulle |
|---|---|---|---|---|---|---|---|
| 1 | `LPVideoOverall` | `Overall Quality` | Slider + readout | 0–3 | 1 | `OnOverallChanged` | `Preset applied to shadows, textures, effects and anti-aliasing. Custom when they differ.` |
| 2 | `LPVideoShadows` | `Shadows` | Slider + readout | 0–3 | 1 | `OnShadowsChanged` | `Shadow resolution and distance.` |
| 3 | `LPVideoTextures` | `Textures` | Slider + readout | 0–3 | 1 | `OnTexturesChanged` | `Texture filtering and streaming budget.` |
| 4 | `LPVideoEffects` | `Effects` | Slider + readout | 0–3 | 1 | `OnEffectsChanged` | `Translucency, refraction, subsurface scattering and particles.` |
| 5 | `LPVideoAA` | `Anti-Aliasing` | Slider + readout | 0–3 | 1 | `OnAAChanged` | `Anti-aliasing quality. The method itself is fixed.` |
| 6 | `LPVideoRecommended` | `Recommended` | `UButton` + `UTextBlock` | — | — | `OnRecommendedClicked` | `Apply the recommended preset for this PC.` |
Les libellés contiennent les mots-clés `shadow` (ligne 2) / `quality` (ligne 1) ; sans importance, `CategoryOf` classe sur le préfixe `LPVideo` avant le texte (§9).
### 8.4 Structure d'une ligne
Copie du patron `ColorGrade::AddRow` : `UHorizontalBox` nommé (`NewObject<UHorizontalBox>(Scroll, Name)`) contenant `USizeBox` 220 px + `UTextBlock` (légende), `USlider` (Fill), `UTextBlock` (readout). Mêmes paddings (10/0/20/0, 0/0/20/0, 0/0/10/0 ; bas 10 px côté scroll). Style : police/couleur/`FSliderStyle` copiés d'une ligne existante via `FindLook` (repli `MenuFont()` : `Regular 20`, contour noir 2 px, opacité 0.95).
Particularités sliders : `SetMinValue(0)`, `SetMaxValue(3)`, `SetStepSize(1)`, `SetLocked(false)`. Branchement `OnValueChanged` via `FScriptDelegate::BindUFunction` (patron ColorGrade). La ligne Recommended : `UHorizontalBox` → `UButton` (`OnClicked` via `FScriptDelegate`) contenant un `UTextBlock` `Recommended`, même police/couleur, alignement gauche, padding 10 px.
Les helpers `FindLook` / `MenuFont` sont **dupliqués** dans un `namespace LPVideoQuality` local au `.cpp` (V1 : aucune modification de `LPColorGradeSubsystem`). Refactor commun éventuel hors périmètre.
### 8.5 Position d'insertion : avant le premier `LPColor*`
Algorithme :
1. `InsertAt = INDEX_NONE` ; parcourir les enfants directs du scroll, `InsertAt` = index du **premier** enfant dont `GetName().StartsWith(TEXT("LPColor"))`.
2. Créer les 6 lignes (jamais ajoutées directement).
3. Si `InsertAt != INDEX_NONE` : pour `i = 0..5`, `Scroll->InsertChildAt(InsertAt + i, Row[i])` (`UPanelWidget::InsertChildAt`) ; configurer ensuite le `UScrollBoxSlot` (padding bas 10) via `Cast<UScrollBoxSlot>(Row->Slot)`.
4. Sinon (`LPColor*` pas encore injecté) : `Scroll->AddChild(Row[i])` dans l'ordre → les lignes seront suivies des `LPColor*` quand ColorGrade s'injectera (il ajoute en fin).
5. **Repli** si `InsertChildAt` retourne `nullptr` : `AddChild` des 6 lignes, puis réordonner : collecter les enfants `LPColor*`, `RemoveChild` de chacun, et les `AddChild` de nouveau dans leur ordre d'origine (même objets ; `ColorGrade` garde ses `TWeakObjectPtr` valides).
Le garde de `ColorGrade::EnsureControls` (`LPColorBrightness` présent) n'est pas affecté par l'ordre. Les deux sous-systèmes sont indépendants (aucune référence croisée).
### 8.6 Masquage des widgets BP redondants
Widgets dirigés vers **Collapsed** : `ShadowQuality`, `PostProcessQuality`, `PostProcessAAQuality` (noms UMG, VQ0-CART §5). Conservés visibles : `ScreenPercentage`, `FullscreenButton`, `ShowFPS`, cases et SpinBoxes (VQ0-CART §8).
Deux niveaux de mise en œuvre :
1. **`EnsureControls()` (VQ2-T2)** : après injection, `SetVisibility(Collapsed)` sur ces trois enfants directs (recherche par `GetFName()`). Sert d'effet immédiat.
2. **`LPSettingsSubsystem` (VQ2-T3)** : **obligatoire** pour la durabilité — `ApplyRows()` remet `Visible` toute ligne dont `Category == ActiveTab` à chaque changement d'onglet ; sans la modification du §9, le masquage du point 1 est annulé au premier clic d'onglet.
Conséquences V1 (pragmatique, documentées VQ0-CART §6.1/§6.2) :
- Le curseur `PP Quality (FPS intensive)` disparaît ; `sg.PostProcessQuality` n'est plus piloté par l'utilisateur et reste à sa dernière valeur / valeur moteur (VQ1 §2.2 : groupe hors périmètre). Voir question Q1.
- Le curseur `AA Quality` BP (écriture directe de `r.PostProcessAAQuality`) est remplacé par `LPVideoAA` (`sg.AntiAliasingQuality`, VQ1 §4.4 : 0/2/4/4).
- Aucune synchronisation BP ↔ LPVideo : les widgets masqués ne sont plus lus ni écrits par le joueur.
## 9. Intégration `LPSettingsSubsystem` (fiche VQ2-T3, rappel de contrat)
Dans `LPSettingsUi::CategoryOf`, **avant** `RowText` (même endroit que la règle `LPColor`) :
```cpp
if (Widget && (Widget->GetName().StartsWith(TEXT("LPColor")) || Widget->GetName().StartsWith(TEXT("LPVideo"))))
{
return TabVideo;
}
```
Et, pour les trois widgets BP masqués, une catégorie « toujours masquée » :
```cpp
constexpr int32 TabHidden = -2;
// dans CategoryOf, après BackButton :
if (Widget && (Widget->GetFName() == TEXT("ShadowQuality")
|| Widget->GetFName() == TEXT("PostProcessQuality")
|| Widget->GetFName() == TEXT("PostProcessAAQuality")))
{
return TabHidden;
}
```
`ApplyRows()` : `Category == -1` reste « toujours visible » (`BackButton`) ; `Category == TabHidden` → `Collapsed` quel que soit l'onglet. **Attention :** l'actuel `bShow = Category < 0 || ...` rendrait `-2` visible ; la condition doit devenir `Category == -1 || (...)`. Aucun autre appelant de `CategoryOf` ne doit traiter `< 0` comme « visible » (à vérifier par Grep en T3).
Ordre dans le `ScrollBox` : `ApplyRows()` ne change que la visibilité, pas l'ordre ; l'ordre « BP, LPVideo*, LPColor* » est donc stable. Après injection tardive de `LPVideo*`, la visibilité initiale des nouvelles lignes est celle de `SWidget` par défaut (visible) tant que `ApplyRows()` n'a pas été rappelé : **T3 doit déclencher `ApplyRows()` après injection** (ou `LPVideo` ne peut pas l'appeler : voir Q2).
## 10. Journalisation
Style ColorGrade, `LogLifePlay`, `Display`, français sans accents dans les littéraux (cohérence existante) :
- `Video : relu ... depuis <ini>` (Load)
- `Video : Overall %d, Shadows %d, Textures %d, Effects %d, AA %d enregistres dans <ini>` (Save)
- `Video : qualite appliquee (sg.ShadowQuality %d, sg.TextureQuality %d, sg.EffectsQuality %d, sg.AntiAliasingQuality %d)` (Apply)
- `Video : controles ajoutes avant les reglages de couleur` (EnsureControls, une fois par injection)
- `Warning` : écart de relecture Scalability (§6.1), `InsertChildAt` en repli (§8.5).
## 11. Risques
| ID | Risque | Mitigation |
|---|---|---|
| R1 | Le Blueprint (`SettingSlider`/`GameSettings`) a pu écrire `sg.*` ou `r.PostProcessAAQuality` avec une priorité **Console/Code**, supérieure à `SetByScalability` : `SetQualityLevels` serait alors ignoré silencieusement. | Relecture + repli `ECVF_SetByConsole` (§6.1) ; ré-application aux déclencheurs b/c (§6.3). À confirmer en VQ2-VAL (valeurs `r.Shadow.MaxResolution`, `r.Streaming.PoolSize`, `r.PostProcessAAQuality` conformes à VQ1 §4). |
| R2 | `ApplyRows()` annule le masquage des widgets BP. | §9 obligatoire (T3). |
| R3 | `InsertChildAt` indisponible/inopérant sur `UScrollBox`. | Repli §8.5.5. |
| R4 | Perte de `PP Quality` : joueur ne peut plus baisser le coût post-process. | Décision V1 assumée ; voir Q1. |
| R5 | `GameUserSettings.ini` contient des `sg.*` différents de `LPVideoQuality.ini`. | `LPVideoQuality.ini` est prioritaire dès qu'il existe (§6.1) ; sans INI, état moteur conservé (§6.2). |
| R6 | Écart d'ordre d'initialisation ColorGrade/Video. | Insertion avant premier `LPColor*` si présent, sinon append ; les deux ordres donnent « Video avant Color » (§8.5). |
| R7 | Interdits VQ1 (GI, VSM, méthode AA, reflets). | Seuls 4 champs de `FQualityLevels` écrits ; aucune cvar écrite directement hors repli R1 (`sg.*` uniquement). |
## 12. Questions ouvertes
| ID | Question | Proposition par défaut |
|---|---|---|
| Q1 | Faut-il lier `sg.PostProcessQuality` à `Effects` (ou `Overall`) pour ne pas figer un groupe devenu sans contrôle ? | **Non** en V1 (VQ1 §5 : 4 groupes seulement). À trancher avec A-VAL après mesure de coût. |
| Q2 | Comment T3 est-il notifié d'une injection pour relancer `ApplyRows()` ? | `ULPSettingsSubsystem` a son propre ticker : `ApplyRows()` est déjà réexécuté après détection de nouvelles lignes (à vérifier en T3) ; sinon un delegate `FSimpleMulticastDelegate OnControlsInjected` exposé par `ULPVideoQualitySubsystem`. |
| Q3 | Niveau recommandé du stub. | `High` (2) ; VQ3 le remplace par un calcul VRAM. |
## 13. Critères d'acceptation (VQ2-T2 / VQ2-T3 / VQ2-VAL)
- [ ] `LPVideoQualitySubsystem.h/.cpp` compilent sans toucher `LifePlay.Build.cs` ni `LPColorGradeSubsystem.*`.
- [ ] Ordre dans l'onglet Video : lignes BP visibles → `LPVideoOverall`, `Shadows`, `Textures`, `Effects`, `AA`, `Recommended` → `LPColorBrightness` … `LPColorTint` (quel que soit l'ordre d'initialisation ColorGrade/Video).
- [ ] `ShadowQuality`, `PostProcessQuality`, `PostProcessAAQuality` restent masqués après changement d'onglet aller-retour.
- [ ] Overall 0→3 : les 4 curseurs suivent, readouts `Low/Med/High/Epic` ; `sg.ShadowQuality`, `sg.TextureQuality`, `sg.EffectsQuality`, `sg.AntiAliasingQuality` valent le niveau choisi (console `sg.X` en lecture) ; `sg.GlobalIlluminationQuality`, `sg.ReflectionQuality`, `sg.PostProcessQuality`, `sg.ViewDistanceQuality` inchangés.
- [ ] Modifier un curseur individuel → Overall affiche `Custom` ; ramener les 4 au même niveau → Overall réaffiche ce niveau.
- [ ] Redémarrage du jeu : niveaux restaurés depuis `Saved/Config/Windows/LPVideoQuality.ini` ; sans INI, aucun changement par rapport à l'état actuel.
- [ ] Fermer/rouvrir le menu : lignes réinjectées avec les bonnes valeurs.
- [ ] `Recommended` : applique High, ne plante pas, log stub.
- [ ] Niveau 3 : GI dynamique, VSM et méthode d'AA inchangés (Lumen/VSM off, TAA) — rappel VQ1 §8.
- [ ] Aucune occurrence de `Virtual`, `Lumen`, `AntiAliasingMethod`, `ReflectionMethod` dans le code ajouté.