From 4903cad9e315bbe2d6007f80d095bb9c7d43ff73 Mon Sep 17 00:00:00 2001
From: ApolloF <98666317+ApolloF@users.noreply.github.com>
Date: Wed, 21 Oct 2026 15:33:12 +0200
Subject: [PATCH 1/2] WIP Luma: catalog model, handoff notes
---
src/DLSSUpdater/Core/CatalogLuma.cs | 71 ++++++++++++++++++++++++++++
src/DLSSUpdater/Core/CatalogModel.cs | 1 +
2 files changed, 72 insertions(+)
create mode 100644 src/DLSSUpdater/Core/CatalogLuma.cs
diff --git a/src/DLSSUpdater/Core/CatalogLuma.cs b/src/DLSSUpdater/Core/CatalogLuma.cs
new file mode 100644
index 0000000..4e15f57
--- /dev/null
+++ b/src/DLSSUpdater/Core/CatalogLuma.cs
@@ -0,0 +1,71 @@
+namespace DLSSUpdater.Core;
+
+///
+/// The catalog's luma section: the Luma Framework (Filoppi/Luma-Framework) mods the wiki lists, the generic Unreal Engine mod
+/// with its game table, and the pinned release zips. The licence is NOASSERTION, so the files are the author's own URLs plus a hash.
+///
+public sealed class CatalogLuma
+{
+ public string? Updated { get; set; }
+ /// The upstream release tag the zips come from ("latest-755").
+ public string Release { get; set; } = "";
+ public List Packages { get; set; } = [];
+ public List Mods { get; set; } = [];
+ /// Id of the package that is the generic Unreal Engine mod.
+ public string UnrealPackage { get; set; } = "Unreal_Engine";
+ public List UnrealGames { get; set; } = [];
+
+ public CatalogLumaPackage? PackageOf(string? id) =>
+ id is null ? null : Packages.FirstOrDefault(p => p.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
+}
+
+/// One release zip: a file per bitness (x64 = Luma-<id>.zip, x86 = Luma-<id>-x32.zip).
+public sealed class CatalogLumaPackage
+{
+ public string Id { get; set; } = "";
+ public List Files { get; set; } = [];
+
+ ///
+ /// The file for a game of (32, 64, or 0 when the exe couldn't be read: x64 first, else x86). Only files with
+ /// a hash count.
+ ///
+ public CatalogBinary? FileFor(int bits)
+ {
+ var usable = Files.Where(f => f.IsInstallable).ToList();
+ CatalogBinary? Of(string arch) => usable.FirstOrDefault(f => f.Arch.Equals(arch, StringComparison.OrdinalIgnoreCase));
+ return bits switch { 32 => Of("x86"), 64 => Of("x64"), _ => Of("x64") ?? Of("x86") };
+ }
+}
+
+public sealed class CatalogLumaMod
+{
+ public string Id { get; set; } = "";
+ public string Name { get; set; } = "";
+ public List Authors { get; set; } = [];
+ /// "working", "in-progress", or null for a release zip the wiki doesn't list.
+ public string? Status { get; set; }
+ public string? Notes { get; set; }
+ public CatalogLinks? Links { get; set; }
+ /// The package the mod is in; null when the author publishes it elsewhere (Nexus Mods).
+ public string? Package { get; set; }
+ public bool RequiresDgVoodoo { get; set; }
+ public List DgVoodooVersions { get; set; } = [];
+ public List DgVoodooSetup { get; set; } = [];
+ public string? FeaturesUrl { get; set; }
+}
+
+/// A row of the wiki's generic Unreal Engine table.
+public sealed class CatalogLumaUnrealGame
+{
+ public string Name { get; set; } = "";
+ /// "yes", "blocked", "in-progress" or null (empty cell).
+ public string? Dlss { get; set; }
+ public string? Hdr { get; set; }
+ public string? UeVersion { get; set; }
+ public string? Notes { get; set; }
+ public List EngineIni { get; set; } = [];
+ public List LaunchArgs { get; set; } = [];
+
+ /// The wiki marks DLSS/FSR blocked and has no HDR for the game: the generic mod does nothing useful there.
+ public bool Blocked => Dlss == "blocked" && Hdr != "yes";
+}
diff --git a/src/DLSSUpdater/Core/CatalogModel.cs b/src/DLSSUpdater/Core/CatalogModel.cs
index 1c49caf..22fcf63 100644
--- a/src/DLSSUpdater/Core/CatalogModel.cs
+++ b/src/DLSSUpdater/Core/CatalogModel.cs
@@ -24,6 +24,7 @@ public sealed class CatalogSections
public CatalogNeural? Neural { get; set; }
/// ReLimiter and Display Commander add-on files (ReLimiter mirrored, Display Commander pinned to the author's URL).
public CatalogFrameLimiters? FrameLimiters { get; set; }
+ public CatalogLuma? Luma { get; set; }
}
public sealed class CatalogFrameLimiters
From 6e33c4c10f07775b19c1d68345eef80a78acfcc2 Mon Sep 17 00:00:00 2001
From: ApolloF <98666317+ApolloF@users.noreply.github.com>
Date: Wed, 21 Oct 2026 17:48:16 +0200
Subject: [PATCH 2/2] Luma component: per-game and generic Unreal mods,
library, install, game page and Settings sections
---
src/DLSSUpdater/Core/AppSettings.cs | 1 +
src/DLSSUpdater/Core/BuildLibrary.cs | 4 +-
src/DLSSUpdater/Core/CatalogState.cs | 3 +
src/DLSSUpdater/Core/ComponentRegistry.cs | 3 +
src/DLSSUpdater/Core/ComponentSettings.cs | 10 +
src/DLSSUpdater/Core/HdrInstall.cs | 5 +-
src/DLSSUpdater/Core/LumaInstall.cs | 201 +++++++++
src/DLSSUpdater/Core/LumaLibrary.cs | 211 ++++++++++
src/DLSSUpdater/Core/LumaPick.cs | 240 +++++++++++
src/DLSSUpdater/Core/MethodAdvisor.cs | 37 +-
src/DLSSUpdater/MainWindow.xaml | 109 +++++
.../ViewModels/GameViewModel.Methods.cs | 1 +
src/DLSSUpdater/ViewModels/GameViewModel.cs | 7 +
src/DLSSUpdater/ViewModels/HdrViewModel.cs | 5 +-
src/DLSSUpdater/ViewModels/HelpTopics.cs | 10 +
.../ViewModels/LumaSettingsViewModel.cs | 80 ++++
src/DLSSUpdater/ViewModels/LumaViewModel.cs | 293 +++++++++++++
src/DLSSUpdater/ViewModels/MainViewModel.cs | 71 +++-
.../ViewModels/SettingsViewModel.cs | 3 +
tests/DLSSUpdater.Tests/LumaFixtures.cs | 85 ++++
tests/DLSSUpdater.Tests/LumaInstallTests.cs | 386 ++++++++++++++++++
tests/DLSSUpdater.Tests/LumaLibraryTests.cs | 173 ++++++++
tests/DLSSUpdater.Tests/LumaPickTests.cs | 171 ++++++++
tests/DLSSUpdater.Tests/LumaViewModelTests.cs | 270 ++++++++++++
tests/DLSSUpdater.Tests/MethodAdvisorTests.cs | 56 ++-
25 files changed, 2411 insertions(+), 24 deletions(-)
create mode 100644 src/DLSSUpdater/Core/LumaInstall.cs
create mode 100644 src/DLSSUpdater/Core/LumaLibrary.cs
create mode 100644 src/DLSSUpdater/Core/LumaPick.cs
create mode 100644 src/DLSSUpdater/ViewModels/LumaSettingsViewModel.cs
create mode 100644 src/DLSSUpdater/ViewModels/LumaViewModel.cs
create mode 100644 tests/DLSSUpdater.Tests/LumaFixtures.cs
create mode 100644 tests/DLSSUpdater.Tests/LumaInstallTests.cs
create mode 100644 tests/DLSSUpdater.Tests/LumaLibraryTests.cs
create mode 100644 tests/DLSSUpdater.Tests/LumaPickTests.cs
create mode 100644 tests/DLSSUpdater.Tests/LumaViewModelTests.cs
diff --git a/src/DLSSUpdater/Core/AppSettings.cs b/src/DLSSUpdater/Core/AppSettings.cs
index aa3031e..d3bc411 100644
--- a/src/DLSSUpdater/Core/AppSettings.cs
+++ b/src/DLSSUpdater/Core/AppSettings.cs
@@ -379,6 +379,7 @@ public void Save()
[JsonSerializable(typeof(NrVersion))]
[JsonSerializable(typeof(AddonBuild))]
[JsonSerializable(typeof(HdrBuild))]
+[JsonSerializable(typeof(LumaBuild))]
[JsonSerializable(typeof(TechOverride))]
[JsonSerializable(typeof(LimiterBuild))]
[JsonSerializable(typeof(ReShadeBuild))]
diff --git a/src/DLSSUpdater/Core/BuildLibrary.cs b/src/DLSSUpdater/Core/BuildLibrary.cs
index 59101e3..92223d7 100644
--- a/src/DLSSUpdater/Core/BuildLibrary.cs
+++ b/src/DLSSUpdater/Core/BuildLibrary.cs
@@ -6,7 +6,7 @@ namespace DLSSUpdater.Core;
/// Every library of kept builds: the downloaded OptiScaler and GITC Uplift releases, the imported add-ons and the NR runtime.
/// Each keeps several builds side by side; settings choose a default and each game may choose its own.
///
-public enum LibraryKind { OptiScaler, Uplift, Lecram, RenoDx, Dfc, DlssNr, RenoDxHdr, ReShade, FrameLimiter, Feeder, Dxvk }
+public enum LibraryKind { OptiScaler, Uplift, Lecram, RenoDx, Dfc, DlssNr, RenoDxHdr, ReShade, FrameLimiter, Feeder, Dxvk, Luma }
///
/// One kept build as the lists and the cleanup see it. is what settings, game picks and install records name
@@ -174,6 +174,7 @@ public sealed partial class ComponentStore
LibraryKind.OptiScaler => ReleaseBuilds(Component.OptiScaler),
LibraryKind.Uplift => ReleaseBuilds(Component.Uplift),
LibraryKind.RenoDxHdr => HdrLibraryBuilds(),
+ LibraryKind.Luma => LumaLibraryBuilds(),
LibraryKind.FrameLimiter => LimiterLibraryBuilds(),
LibraryKind.ReShade => ReShadeBuilds.Select(b => new LibraryBuild(kind, b.Id, b.Label, b.Version, b.Added, ReShadeBuildDir(b), Downloaded: b.Downloaded)).ToList(),
LibraryKind.Feeder => FeederLibrary.Builds(),
@@ -574,6 +575,7 @@ public long RunCleanup(IEnumerable items)
ReleasesChanged();
foreach (var kind in Enum.GetValues()) ForgetAddons(kind);
ForgetHdr();
+ ForgetLuma();
ForgetLimiters();
ForgetReShade();
if (count > 0) Log.Info($"Cleanup freed {BuildCleanup.FormatBytes(freed)} ({count} item(s))");
diff --git a/src/DLSSUpdater/Core/CatalogState.cs b/src/DLSSUpdater/Core/CatalogState.cs
index e0f80bd..53a98b3 100644
--- a/src/DLSSUpdater/Core/CatalogState.cs
+++ b/src/DLSSUpdater/Core/CatalogState.cs
@@ -10,6 +10,8 @@ public sealed class CatalogState(CatalogClient client)
public CatalogOrigin Origin { get; private set; } = CatalogOrigin.None;
public string? Note { get; private set; }
public HdrPicker? Picker { get; private set; }
+ /// The Luma mods of the catalog; null when the catalog has no luma section.
+ public LumaPicker? LumaPicker { get; private set; }
public event Action? Changed;
@@ -27,6 +29,7 @@ private void Apply(CatalogLoad load)
changed = Document?.Generated != load.Document?.Generated || (Document is null) != (load.Document is null);
Document = load.Document;
Picker = load.Document?.Sections.Renodx is { } renodx ? new HdrPicker(renodx) : null;
+ LumaPicker = load.Document?.Sections.Luma is { } luma ? new LumaPicker(luma) : null;
}
Origin = load.Origin;
Note = load.Note;
diff --git a/src/DLSSUpdater/Core/ComponentRegistry.cs b/src/DLSSUpdater/Core/ComponentRegistry.cs
index 9e0f440..eb0ec67 100644
--- a/src/DLSSUpdater/Core/ComponentRegistry.cs
+++ b/src/DLSSUpdater/Core/ComponentRegistry.cs
@@ -56,6 +56,8 @@ public static class ComponentRegistry
public const string Feeder = "dlss5-feeder";
/// DXVK (Direct3D 8-11 over Vulkan): stable, development and the Lilium HDR fork; recorded the generic way ().
public const string Dxvk = "dxvk";
+ /// Luma Framework mods (Filoppi/Luma-Framework): a ReShade add-on plus its Luma\ folder, per game or the generic Unreal one ().
+ public const string Luma = "luma";
/// Not a component with files: the per-game record of what the app wrote into the NVIDIA driver profile ().
public const string NvidiaDriver = "nvidia-driver";
/// The ReShade add-on frame limiters (ids match the catalog's managedBy) and the Unreal Engine.ini cap; one is active per game.
@@ -98,6 +100,7 @@ static ComponentRegistry()
});
Register(new(UnrealFrameCap, "Unreal frame cap") { Label = "UE frame cap" });
Register(new(Dxvk, "DXVK") { Library = LibraryKind.Dxvk, Label = "DXVK", OwnedFiles = [.. DxvkDlls.All, DxvkConf.FileName] });
+ Register(new(Luma, "Luma") { Library = LibraryKind.Luma, Label = "Luma", OwnedFiles = [LumaFiles.TreeDir + @"\*", "Luma-*.addon", "Luma-*.addon64", "Luma-*.addon32"] });
Register(new(Feeder, "DLSS5-Feeder") { Library = LibraryKind.Feeder, Label = "Feeder", OwnedFiles = FeederLibrary.OwnedFiles });
}
diff --git a/src/DLSSUpdater/Core/ComponentSettings.cs b/src/DLSSUpdater/Core/ComponentSettings.cs
index acf1cad..b510b2d 100644
--- a/src/DLSSUpdater/Core/ComponentSettings.cs
+++ b/src/DLSSUpdater/Core/ComponentSettings.cs
@@ -134,6 +134,16 @@ public void SetHdrUeExtended(bool? on) =>
public void SetEngineIniPath(string? path) =>
Components = ComponentPicks.SetOption(Components, ComponentRegistry.RenoDxHdr, "engine-ini", string.IsNullOrWhiteSpace(path) ? null : path.Trim());
+ /// The Luma mod the user picked by hand for this game: a catalog mod id, , or (an imported zip, whose library id is the build pick); null = automatic.
+ public string? LumaMod => ComponentPicks.GetOption(Components, ComponentRegistry.Luma, "mod");
+
+ public void SetLumaMod(string? mod) => Components = ComponentPicks.SetOption(Components, ComponentRegistry.Luma, "mod", mod);
+
+ /// HDR output for Luma in this game: true = on, false = off, null = leave it to Luma (it sets it from the display on first start).
+ public bool? LumaHdr => ComponentPicks.GetOption(Components, ComponentRegistry.Luma, "hdr") switch { "on" => true, "off" => false, _ => null };
+
+ public void SetLumaHdr(bool? on) => Components = ComponentPicks.SetOption(Components, ComponentRegistry.Luma, "hdr", on is null ? null : on.Value ? "on" : "off");
+
/// Replaces this game's option values for a component in the map; null or empty removes them.
public void SetPickOptions(string id, Dictionary? options) => Components = ComponentPicks.SetOptions(Components, id, options);
}
diff --git a/src/DLSSUpdater/Core/HdrInstall.cs b/src/DLSSUpdater/Core/HdrInstall.cs
index 8f10ca1..62ceea0 100644
--- a/src/DLSSUpdater/Core/HdrInstall.cs
+++ b/src/DLSSUpdater/Core/HdrInstall.cs
@@ -158,7 +158,7 @@ public sealed class HdrService(ComponentStore store, Installer installer, Catalo
/// OptiScaler, where the user decides about ReShade.
///
public async Task InstallAsync(GameInfo game, string targetDir, HdrChoice choice, int? peakNits, string? engineIniHint,
- HdrReShade? reshade, IProgress? progress, CancellationToken ct, IReadOnlyList? userEdits = null)
+ HdrReShade? reshade, IProgress? progress, CancellationToken ct, IReadOnlyList? userEdits = null, bool lumaClashConfirmed = false)
{
if (game.AntiCheat is { } ac) throw new InvalidOperationException($"{game.Name} has {ac} anti-cheat: no add-on is installed in games with anti-cheat.");
if (!choice.IsOffered) throw new InvalidOperationException(choice.Reason);
@@ -172,7 +172,8 @@ public async Task InstallAsync(GameInfo game, string targetDir
var plan = HdrPlanner.Build(game, targetDir, choice, build, ComponentStore.HdrPathOf(build), peakNits, engineIniHint, existing, userEdits);
foreach (var w in plan.Warnings) Log.Warn($"{game.Name}: {w}");
progress?.Report(new TransferProgress($"Installing {choice.Title} to {game.Name}", null));
- var undo = await installer.InstallComponentAsync(game, targetDir, plan.Plan, ct);
+ var component = plan.Plan with { Extras = LumaConflict.WithMark(plan.Plan.Extras, lumaClashConfirmed) };
+ var undo = await installer.InstallComponentAsync(game, targetDir, component, ct);
return new HdrInstallResult(build, plan, undo);
}
diff --git a/src/DLSSUpdater/Core/LumaInstall.cs b/src/DLSSUpdater/Core/LumaInstall.cs
new file mode 100644
index 0000000..197695e
--- /dev/null
+++ b/src/DLSSUpdater/Core/LumaInstall.cs
@@ -0,0 +1,201 @@
+using DLSSUpdater.Scan;
+
+namespace DLSSUpdater.Core;
+
+/// The reshade.ini keys Luma documents (Source/Core/core.hpp in Filoppi/Luma-Framework): section [Luma], EnableHDR and DisplayMode.
+public static class LumaIni
+{
+ public const string Section = "Luma", EnableHdr = "EnableHDR", DisplayMode = "DisplayMode";
+
+ /// Nothing for Auto (Luma sets HDR from the display on its first start); On or Off write both keys, because the user chose them here.
+ public static IReadOnlyList<(string Section, string Key, string Value)> ReShadeKeys(bool? hdr) => hdr is { } on
+ ? [(Section, EnableHdr, on ? "1" : "0"), (Section, DisplayMode, on ? "1" : "0")]
+ : [];
+}
+
+/// The RenoDX HDR component and Luma in one game: both rework the HDR output, so installing the second is a warning the user confirms.
+public static class LumaConflict
+{
+ public const string ClashKey = "clash", ClashValue = "renodx-luma";
+
+ public const string Message =
+ "RenoDX HDR is installed in this game, and Luma also reworks the game's HDR output. The two don't stack: expect a broken picture or a crash. " +
+ "Remove one of them, or install anyway if you know what you're doing.";
+
+ public const string MessageForRenoDx =
+ "Luma is installed in this game, and RenoDX HDR also reworks the game's HDR output. The two don't stack: expect a broken picture or a crash. " +
+ "Remove one of them, or install anyway if you know what you're doing.";
+
+ /// Both are in the game now (record-wise).
+ public static bool Clashes(InstallManifest? m) =>
+ m?.Component(ComponentRegistry.Luma) is not null && m.Component(ComponentRegistry.RenoDxHdr) is not null;
+
+ /// The user already said yes to this pair: either component's record carries the mark.
+ public static bool Confirmed(InstallManifest? m) =>
+ m?.Component(ComponentRegistry.Luma)?.Extras.GetValueOrDefault(ClashKey) == ClashValue
+ || m?.Component(ComponentRegistry.RenoDxHdr)?.Extras.GetValueOrDefault(ClashKey) == ClashValue;
+
+ /// The warning for installing Luma now (RenoDX HDR is there), else null.
+ public static string? ForLuma(InstallManifest? m) => m?.Component(ComponentRegistry.RenoDxHdr) is not null ? Message : null;
+
+ /// The warning for installing RenoDX HDR now (Luma is there), else null.
+ public static string? ForRenoDx(InstallManifest? m) => m?.Component(ComponentRegistry.Luma) is not null ? MessageForRenoDx : null;
+
+ public static IReadOnlyDictionary WithMark(IReadOnlyDictionary extras, bool confirmed)
+ {
+ if (!confirmed) return extras;
+ var d = new Dictionary(extras) { [ClashKey] = ClashValue };
+ return d;
+ }
+}
+
+/// A plan for the installer plus where its pieces went, for the log and the tests.
+public sealed record LumaPlan(ComponentPlan Plan, IReadOnlyList AddonFiles, EngineIniTarget? EngineIni, IReadOnlyList Warnings);
+
+/// Builds the component plan for a pick: the zip's files (add-on to the add-on folder, the Luma\ tree beside the exe), reshade.ini and Engine.ini keys. No writes.
+public static class LumaPlanner
+{
+ public const string SignatureKey = "signature", ModKey = "mod", PackageKey = "package", EngineIniKey = "engineIni";
+
+ /// The zip entries that were unpacked into ( applied).
+ public static LumaPlan Build(GameInfo game, string targetDir, LumaChoice choice, LumaBuild build, string packageDir, IReadOnlyList names,
+ bool? hdr, string? engineIniHint, InstalledComponent? existing)
+ {
+ if (!choice.IsOffered) throw new InvalidOperationException(choice.Reason);
+ targetDir = FileUtil.Normalize(targetDir);
+ var warnings = new List();
+ var addonDir = ReShadeAddonPath.Resolve(targetDir, game.Root);
+
+ var files = names.Select(n => (Source: Path.Combine(packageDir, n.Replace('/', Path.DirectorySeparatorChar)), Dest: LumaFiles.Destination(n, targetDir, addonDir))).ToList();
+ var addons = files.Where(f => LumaFiles.IsAddon(f.Dest)).Select(f => Path.GetFileName(f.Dest)).ToList();
+ if (addons.Count == 0) throw new InvalidDataException($"{build.FileName} has no ReShade add-on in it.");
+ foreach (var other in OtherLumaAddons(addonDir, addons))
+ warnings.Add($"{other} is also in the add-on folder; two Luma mods in one game fight over the same hooks.");
+
+ var edits = new List<(string File, string Section, string Key, string Value)>();
+ var reshadeIni = Path.Combine(targetDir, ReShadeAddonPath.IniName);
+ foreach (var (s, k, v) in LumaIni.ReShadeKeys(hdr)) edits.Add((reshadeIni, s, k, v));
+
+ var readOnly = new List();
+ EngineIniTarget? engine = null;
+ if (choice.EngineIni.Count > 0)
+ {
+ var local = InstallManifest.KnownFolder("|localappdata|") ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ var docs = InstallManifest.KnownFolder("|documents|") ?? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
+ engine = EngineIniLocator.Resolve(game, engineIniHint, local, docs);
+ if (engine is null)
+ warnings.Add("Engine.ini couldn't be placed (the game's Unreal project wasn't found), so the wiki's Engine.ini lines were not written. They are listed on the page.");
+ else
+ {
+ var entry = InstallManifest.ConfigEntry(FileUtil.Normalize(engine.Path), targetDir, FileUtil.Normalize(game.Root));
+ var wanted = choice.EngineIni.Select(k => (k.Section, k.Key, k.Value));
+ foreach (var (s, k, v) in HdrIni.MissingOnly(Read(engine.Path), existing, entry, wanted)) edits.Add((engine.Path, s, k, v));
+ readOnly.Add(engine.Path);
+ }
+ }
+
+ var plan = new ComponentPlan(ComponentRegistry.Luma)
+ {
+ BuildId = build.Id,
+ Version = build.Version,
+ Files = files.Select(f => (f.Source, f.Dest)).ToList(),
+ ConfigEdits = edits,
+ ReadOnly = readOnly,
+ Extras = new Dictionary
+ {
+ [SignatureKey] = choice.Signature(hdr),
+ [ModKey] = choice.ModId ?? "",
+ [PackageKey] = choice.PackageId ?? "",
+ [EngineIniKey] = engine?.Path ?? "",
+ },
+ };
+ return new LumaPlan(plan, addons, engine, warnings);
+ }
+
+ private static IniFile? Read(string path)
+ {
+ try { return File.Exists(path) ? IniFile.Load(path) : null; }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return null; }
+ }
+
+ private static IEnumerable OtherLumaAddons(string dir, IReadOnlyList ours)
+ {
+ if (!Directory.Exists(dir)) return [];
+ try
+ {
+ return Directory.EnumerateFiles(dir, "Luma-*.addon*").Select(Path.GetFileName).OfType()
+ .Where(n => !ours.Contains(n, StringComparer.OrdinalIgnoreCase)).ToList();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return []; }
+ }
+}
+
+/// Installed against catalog for one game, as the game page and Update all use it.
+public sealed record LumaStatus(HdrState State, string? Installed, string? Latest, string Line)
+{
+ public bool UpdateAvailable => State == HdrState.Update;
+
+ public static LumaStatus Of(LumaChoice choice, InstalledComponent? installed, bool? hdr)
+ {
+ if (installed is null)
+ return choice.IsOffered
+ ? new LumaStatus(HdrState.NotInstalled, null, choice.Version, $"Not installed · {choice.Version} available")
+ : new LumaStatus(HdrState.Unavailable, null, null, choice.Reason);
+ var have = installed.Version ?? installed.BuildId?[..Math.Min(12, installed.BuildId.Length)];
+ if (!choice.IsOffered)
+ return new LumaStatus(HdrState.Orphaned, have, null, $"Installed {have}, but nothing is offered for this game now: {choice.Reason}");
+ var latest = choice.Version;
+ if (installed.Extras.GetValueOrDefault(LumaPlanner.SignatureKey) == choice.Signature(hdr) && installed.BuildId == choice.File!.Sha256)
+ return new LumaStatus(HdrState.Current, have, latest, $"Installed {have} · up to date");
+ if (installed.BuildId != choice.File!.Sha256)
+ return new LumaStatus(HdrState.Update, have, latest, installed.Extras.GetValueOrDefault(LumaPlanner.ModKey) == (choice.ModId ?? "")
+ ? $"Installed {have} · update to {latest}"
+ : $"Installed {have} (another mod) · switch to {choice.Title} {latest}");
+ return new LumaStatus(HdrState.Update, have, latest, $"Installed {have} · settings changed, update to apply them");
+ }
+}
+
+public sealed record LumaInstallResult(LumaBuild Build, LumaPlan Plan, ComponentUndo Undo);
+
+/// Installs, updates and removes the Luma component in a game: library download, unpack, ReShade, then one component plan.
+public sealed class LumaService(ComponentStore store, Installer installer, CatalogDownloader downloader)
+{
+ private readonly ReShadeAddonHost host = new(store, installer);
+
+ ///
+ /// Brings the game to the pick. ReShade with add-on support must be in the game: it is installed here through the managed ReShade
+ /// install when the game has none, but not beside OptiScaler, where the user decides about ReShade. The zip's own ReShade dll and
+ /// nvngx_dlss.dll are never copied.
+ ///
+ public async Task InstallAsync(GameInfo game, string targetDir, LumaChoice choice, bool? hdr, string? engineIniHint,
+ HdrReShade? reshade, bool clashConfirmed, IProgress? progress, CancellationToken ct)
+ {
+ if (game.AntiCheat is { } ac) throw new InvalidOperationException($"{game.Name} has {ac} anti-cheat: no add-on is installed in games with anti-cheat.");
+ if (!choice.IsOffered) throw new InvalidOperationException(choice.Reason);
+
+ var build = choice.Pinned is { } pinned
+ ? store.VerifiedLumaBuild(pinned)
+ : await store.EnsureLumaBuildAsync(choice.File!, choice.PackageId ?? choice.Title, choice.Version, downloader, progress, ct);
+
+ // Unpacked fresh from the verified zip every time, so the files placed are exactly the zip's.
+ var pkg = ComponentStore.LumaPackageDirOf(build);
+ var names = await Task.Run(() =>
+ {
+ if (Directory.Exists(pkg)) FileUtil.DeleteDirectory(pkg);
+ return ZipStage.Extract(ComponentStore.LumaPathOf(build), pkg, LumaFiles.Include);
+ }, ct);
+
+ await host.EnsureAsync(game, targetDir, reshade ?? new HdrReShade(), new("Luma", "Luma needs ReShade 6 or newer with add-on support."), progress, ct);
+
+ var existing = InstallManifest.Read(targetDir)?.Component(ComponentRegistry.Luma);
+ var plan = LumaPlanner.Build(game, targetDir, choice, build, pkg, names, hdr, engineIniHint, existing);
+ foreach (var w in plan.Warnings) Log.Warn($"{game.Name}: {w}");
+ progress?.Report(new TransferProgress($"Installing {choice.Title} to {game.Name}", null));
+ var component = plan.Plan with { Extras = LumaConflict.WithMark(plan.Plan.Extras, clashConfirmed) };
+ var undo = await installer.InstallComponentAsync(game, targetDir, component, ct);
+ return new LumaInstallResult(build, plan, undo);
+ }
+
+ public Task RemoveAsync(GameInfo game, string targetDir, CancellationToken ct) =>
+ installer.RemoveComponentAsync(game, targetDir, ComponentRegistry.Luma, ct);
+}
diff --git a/src/DLSSUpdater/Core/LumaLibrary.cs b/src/DLSSUpdater/Core/LumaLibrary.cs
new file mode 100644
index 0000000..aef6d0d
--- /dev/null
+++ b/src/DLSSUpdater/Core/LumaLibrary.cs
@@ -0,0 +1,211 @@
+using System.Globalization;
+using System.IO.Compression;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DLSSUpdater.Core;
+
+///
+/// One Luma zip in the library, components\luma\<sha256>\<FileName> with this record next to it as version.json.
+/// is the upstream release tag the catalog pins ("latest-755"); an imported zip has "imported <hash>".
+///
+public sealed record LumaBuild(
+ string Id,
+ string PackageId,
+ string FileName,
+ string Arch,
+ string Version,
+ string? FirstSeen,
+ string? Url,
+ bool Imported,
+ DateTime Added)
+{
+ [JsonIgnore]
+ public string Label => Imported ? $"{FileName} ({Version})" : $"{PackageId.Replace('_', ' ')} {Version}";
+
+ [JsonIgnore]
+ public DateTime Date => DateTime.TryParse(FirstSeen, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var d) ? d : Added;
+}
+
+///
+/// Which files of a Luma zip go where. The zips are laid out like the game's exe folder: a ReShade add-on, the Luma\ shader folder and
+/// ReShade's own proxy dll, ini and DLSS dll, which the app manages itself and so never copies.
+///
+public static class LumaFiles
+{
+ public const string TreeDir = "Luma";
+
+ private static readonly string[] ProxyDlls =
+ ["dxgi.dll", "d3d8.dll", "d3d9.dll", "d3d10.dll", "d3d11.dll", "d3d12.dll", "opengl32.dll", "ddraw.dll"];
+
+ public static bool IsAddon(string name) =>
+ name.EndsWith(".addon", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".addon64", StringComparison.OrdinalIgnoreCase)
+ || name.EndsWith(".addon32", StringComparison.OrdinalIgnoreCase);
+
+ /// False for what the app doesn't take from the zip: ReShade's proxy dlls, reshade.ini and logs next to the exe, and nvngx_dlss.dll anywhere.
+ public static bool Include(string zipName)
+ {
+ var name = zipName.Replace('\\', '/');
+ var file = name[(name.LastIndexOf('/') + 1)..];
+ if (file.Equals("nvngx_dlss.dll", StringComparison.OrdinalIgnoreCase)) return false;
+ if (name.Contains('/')) return true;
+ return !ProxyDlls.Contains(file, StringComparer.OrdinalIgnoreCase)
+ && !file.Equals("reshade.ini", StringComparison.OrdinalIgnoreCase)
+ && !(file.StartsWith("ReShade", StringComparison.OrdinalIgnoreCase) && file.EndsWith(".log", StringComparison.OrdinalIgnoreCase));
+ }
+
+ /// An add-on goes to ReShade's add-on folder, everything else beside the exe keeping its relative path.
+ public static string Destination(string zipName, string targetDir, string addonDir)
+ {
+ var rel = zipName.Replace('/', Path.DirectorySeparatorChar);
+ return IsAddon(zipName) ? Path.Combine(addonDir, Path.GetFileName(rel)) : Path.Combine(targetDir, rel);
+ }
+}
+
+public sealed partial class ComponentStore
+{
+ private const string LumaDir = "luma";
+ private const string LumaPackageFolder = "pkg";
+ private static string LumaRoot => Path.Combine(AppPaths.Components, LumaDir);
+ private (string Root, IReadOnlyList Builds)? _lumaCache;
+
+ /// Every readable build, newest first.
+ public IReadOnlyList LumaBuilds
+ {
+ get
+ {
+ var root = LumaRoot;
+ lock (_addonCache)
+ {
+ if (_lumaCache is { } c && c.Root == root) return c.Builds;
+ var builds = ReadLumaBuilds(root);
+ _lumaCache = (root, builds);
+ return builds;
+ }
+ }
+ }
+
+ private void ForgetLuma()
+ {
+ lock (_addonCache) _lumaCache = null;
+ }
+
+ private static IReadOnlyList ReadLumaBuilds(string root)
+ {
+ if (!Directory.Exists(root)) return [];
+ var builds = new List();
+ // One level only: the unpacked zip next to a build's record is not a library entry.
+ foreach (var file in Directory.EnumerateDirectories(root).Select(d => Path.Combine(d, BuildRecordFile)).Where(File.Exists))
+ {
+ try
+ {
+ var b = JsonSerializer.Deserialize(File.ReadAllText(file), JsonCtx.Default.LumaBuild);
+ if (b is not null && ValidId(b.Id) && Path.GetFileName(Path.GetDirectoryName(file)) == b.Id
+ && ValidLumaFile(b.FileName) && File.Exists(Path.Combine(root, b.Id, b.FileName))) builds.Add(b);
+ }
+ catch (Exception ex) when (ex is IOException or JsonException)
+ { Log.Error($"Could not read Luma library entry {file}", ex); }
+ }
+ return builds.OrderByDescending(b => b.Date).ThenBy(b => b.Id, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ /// A plain file name ending in .zip: the catalog is trusted for the bytes (it gives the hash), not for a path.
+ internal static bool ValidLumaFile(string? name) =>
+ !string.IsNullOrEmpty(name) && Path.GetFileName(name) == name && name.IndexOfAny(Path.GetInvalidFileNameChars()) < 0
+ && name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase);
+
+ public LumaBuild? LumaBuildFor(string? id) => id is null ? null : LumaBuilds.FirstOrDefault(b => b.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
+
+ public static string LumaPathOf(LumaBuild build) => Path.Combine(LumaRoot, build.Id, build.FileName);
+
+ /// Where a build's files are unpacked at install time.
+ public static string LumaPackageDirOf(LumaBuild build) => Path.Combine(LumaRoot, build.Id, LumaPackageFolder);
+
+ /// A build the game is pinned to: already in the library, so nothing is downloaded, but the file is checked against its hash before it is used.
+ public LumaBuild VerifiedLumaBuild(LumaBuild build)
+ {
+ var path = LumaPathOf(build);
+ if (!File.Exists(path)) throw new FileNotFoundException($"The pinned Luma build {build.Label} is no longer in the library. Pick another build or the default.", path);
+ if (!FileUtil.Sha256(path).Equals(build.Id, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException($"The stored file of {build.Label} changed. Remove the build in Settings and let the app download it again.");
+ return build;
+ }
+
+ private IReadOnlyList LumaLibraryBuilds() => LumaBuilds
+ .Select(b => new LibraryBuild(LibraryKind.Luma, b.Id, b.Label, b.Version, b.Date, Path.Combine(LumaRoot, b.Id), Group: b.PackageId, Downloaded: !b.Imported))
+ .ToList();
+
+ ///
+ /// The library's copy of a catalog zip, downloaded (mirror first, then the author) and checked against the catalog's SHA-256 and size
+ /// when it isn't there yet. A refused download throws with the one-line reason.
+ ///
+ public async Task EnsureLumaBuildAsync(CatalogBinary file, string packageId, string release, CatalogDownloader downloader,
+ IProgress? progress, CancellationToken ct)
+ {
+ if (!file.IsInstallable) throw new InvalidOperationException($"{file.Name}: the catalog has no SHA-256 for it, so it isn't installed.");
+ var sha = file.Sha256!.ToLowerInvariant();
+ if (!ValidId(sha) || !ValidLumaFile(file.Name)) throw new InvalidDataException($"The catalog entry {file.Name} isn't a Luma zip.");
+
+ var dir = Path.Combine(LumaRoot, sha);
+ var dest = Path.Combine(dir, file.Name);
+ if (LumaBuildFor(sha) is { } have && File.Exists(dest) && FileUtil.Sha256(dest).Equals(sha, StringComparison.OrdinalIgnoreCase)) return have;
+
+ Directory.CreateDirectory(dir);
+ var result = await downloader.DownloadAsync(file, dest, progress, ct);
+ if (!result.Ok)
+ {
+ FileUtil.TryDeleteEmptyDir(dir);
+ throw new InvalidOperationException(result.Refusal ?? $"{file.Name} could not be downloaded.");
+ }
+
+ var build = new LumaBuild(sha, packageId, file.Name, file.Arch, release, file.FirstSeen, file.Url, false, DateTime.UtcNow);
+ FileUtil.AtomicWriteText(Path.Combine(dir, BuildRecordFile), JsonSerializer.Serialize(build, JsonCtx.Default.LumaBuild));
+ ForgetLuma();
+ NoteAdded(sha);
+ Log.Info($"Luma: downloaded {build.Label} ({file.Name}){(result.FromMirror ? " from the catalog mirror" : "")}");
+ return build;
+ }
+
+ ///
+ /// Adds a Luma zip the user has to the library under "custom". It must be a zip that holds a ReShade add-on. There is no catalog hash
+ /// to check it against, so the copy's own SHA-256 becomes its id and every install checks the stored file against it.
+ ///
+ public LumaBuild ImportLumaBuild(string path)
+ {
+ var name = Path.GetFileName(path);
+ if (!ValidLumaFile(name)) throw new InvalidDataException($"{name} isn't a zip file.");
+ var info = new FileInfo(path);
+ if (!info.Exists) throw new FileNotFoundException("The zip wasn't found.", path);
+ if (info.Length is 0 or > 1L << 30) throw new InvalidDataException($"{name} has an unexpected size.");
+ string? addon;
+ try
+ {
+ using var zip = ZipFile.OpenRead(path);
+ addon = zip.Entries.Select(e => e.FullName).FirstOrDefault(LumaFiles.IsAddon);
+ }
+ catch (InvalidDataException) { throw new InvalidDataException($"{name} isn't a readable zip file."); }
+ if (addon is null) throw new InvalidDataException($"{name} has no ReShade add-on (.addon64 or .addon32) in it, so it isn't a Luma zip.");
+
+ var sha = FileUtil.Sha256(path).ToLowerInvariant();
+ if (LumaBuildFor(sha) is { } have && File.Exists(LumaPathOf(have))) return have;
+
+ var dir = Path.Combine(LumaRoot, sha);
+ Directory.CreateDirectory(dir);
+ File.Copy(path, Path.Combine(dir, name), overwrite: true);
+ var arch = addon.EndsWith(".addon32", StringComparison.OrdinalIgnoreCase) ? "x86" : "x64";
+ var build = new LumaBuild(sha, LumaManual.ImportedPackageId, name, arch, "imported " + sha[..8], null, null, true, DateTime.UtcNow);
+ FileUtil.AtomicWriteText(Path.Combine(dir, BuildRecordFile), JsonSerializer.Serialize(build, JsonCtx.Default.LumaBuild));
+ ForgetLuma();
+ NoteAdded(sha);
+ Log.Info($"Luma: imported {name}");
+ return build;
+ }
+
+ public void RemoveLuma(string id)
+ {
+ if (!ValidId(id)) throw new InvalidDataException("Invalid Luma build id.");
+ var dir = Path.Combine(LumaRoot, id);
+ if (Directory.Exists(dir)) FileUtil.DeleteDirectory(dir);
+ ForgetLuma();
+ }
+}
diff --git a/src/DLSSUpdater/Core/LumaPick.cs b/src/DLSSUpdater/Core/LumaPick.cs
new file mode 100644
index 0000000..ae13b50
--- /dev/null
+++ b/src/DLSSUpdater/Core/LumaPick.cs
@@ -0,0 +1,240 @@
+using DLSSUpdater.Scan;
+
+namespace DLSSUpdater.Core;
+
+public enum LumaKind { None, Named, Unreal, Custom }
+
+///
+/// What the Luma component would install in one game, and why. None means nothing is offered and
+/// says why in one line.
+///
+public sealed record LumaChoice
+{
+ public LumaKind Kind { get; init; }
+ /// The catalog mod id for a named mod, "unreal" for the generic Unreal mod, "custom" for an imported zip.
+ public string? ModId { get; init; }
+ public string Title { get; init; } = "";
+ public string Reason { get; init; } = "";
+ public CatalogBinary? File { get; init; }
+ /// The package id in the catalog ("Unreal_Engine", "Prey"), the library group.
+ public string? PackageId { get; init; }
+ /// The release the zip belongs to ("latest-755"): the version shown and recorded.
+ public string Version { get; init; } = "";
+ /// The library build the game is pinned to (); null follows the catalog's file above.
+ public LumaBuild? Pinned { get; init; }
+ public CatalogLumaMod? Mod { get; init; }
+ public CatalogLumaUnrealGame? UnrealGame { get; init; }
+ public string? Link { get; init; }
+ public IReadOnlyList Notes { get; init; } = [];
+ /// Engine.ini keys the wiki lists for this Unreal game; written on install.
+ public IReadOnlyList EngineIni { get; init; } = [];
+ /// Launch arguments the wiki lists (-dx11); the app can't set Steam launch options, so they are shown with a copy button.
+ public IReadOnlyList LaunchArgs { get; init; } = [];
+ public bool NeedsDgVoodoo { get; init; }
+ public IReadOnlyList DgVoodooLines { get; init; } = [];
+
+ public bool IsOffered => Kind != LumaKind.None && File is not null;
+
+ /// Everything the files and ini edits depend on; an install is current while its stored signature matches.
+ public string Signature(bool? hdr) =>
+ string.Join('|', Kind, ModId, File?.Sha256, hdr is null ? "" : hdr.Value ? "hdr-on" : "hdr-off",
+ string.Join(',', EngineIni.Select(k => $"{k.Section}/{k.Key}={k.Value}")));
+}
+
+public static class LumaManual
+{
+ /// The "mod" pick of a game that uses a Luma zip the user imported; the zip is the game's Luma build pick.
+ public const string CustomMod = "custom";
+ public const string ImportedPackageId = "custom";
+
+ /// An imported zip, installed as it is: no ini keys of ours, pinned so the install uses the library copy and checks its hash.
+ public static LumaChoice CustomChoice(LumaBuild build, int gameBits = 0) => new()
+ {
+ Notes = (gameBits == 64 && build.Arch == "x86") || (gameBits == 32 && build.Arch == "x64")
+ ? [$"This is a {(build.Arch == "x86" ? "32" : "64")}-bit Luma add-on and the game is {gameBits}-bit; ReShade will not load it."]
+ : [],
+ Kind = LumaKind.Custom, ModId = CustomMod, Title = Path.GetFileNameWithoutExtension(build.FileName), PackageId = ImportedPackageId, Version = build.Version,
+ File = new CatalogBinary { Name = build.FileName, Arch = build.Arch, Url = build.Url ?? "", Sha256 = build.Id, Version = build.Version, FirstSeen = build.FirstSeen },
+ Pinned = build,
+ Reason = $"{build.FileName} is a Luma zip you imported; it is installed as it is.",
+ };
+
+ public static LumaChoice MissingCustom() => new()
+ {
+ Kind = LumaKind.None,
+ Reason = "The Luma zip you imported for this game is no longer in the library. Import it again or reset to auto.",
+ };
+}
+
+///
+/// Which Luma mod a game gets: a mod whose name is the game's name (exact, after normalising; no substring matching), else for a game on
+/// the wiki's generic Unreal table the generic Unreal mod. Pure: it reads the catalog and what the scan found. Luma is never installed
+/// without the user pressing Install; Auto only says what would go in.
+///
+public sealed class LumaPicker
+{
+ public const string UnrealId = "unreal";
+
+ private readonly CatalogLuma _catalog;
+ private readonly List<(string Key, CatalogLumaMod Mod)> _mods;
+ private readonly List<(string Key, CatalogLumaUnrealGame Game)> _unreal;
+
+ public LumaPicker(CatalogLuma catalog)
+ {
+ _catalog = catalog;
+ _mods = catalog.Mods.Select(m => (GameMatcher.Normalize(m.Name), m)).Where(m => m.Item1.Length > 0).ToList();
+ _unreal = catalog.UnrealGames.Select(g => (GameMatcher.Normalize(g.Name), g)).Where(g => g.Item1.Length > 0).ToList();
+ }
+
+ public CatalogLuma Catalog => _catalog;
+
+ /// A catalog mod id or the user picked by hand; null = automatic.
+ public LumaChoice Pick(GameInfo game, string? manualMod = null)
+ {
+ if (game.AntiCheat is { } ac)
+ return None($"{ac} anti-cheat: no add-on is installed in games with anti-cheat.");
+ var bits = game.Effective.Bits == 32 ? 32 : 64;
+ if (manualMod is not null) return ByHand(game, manualMod, bits);
+
+ var key = GameMatcher.Normalize(game.Name);
+ if (key.Length > 0 && _mods.FirstOrDefault(m => m.Key == key).Mod is { } mod)
+ return Named(mod, "its name", bits);
+
+ if (key.Length > 0 && _unreal.FirstOrDefault(g => g.Key == key).Game is { } row)
+ return Unreal(game, bits, row, "The Luma wiki lists this game for the generic Unreal mod. ", hand: false);
+ return None("No Luma mod is known for this game.");
+ }
+
+ /// The wiki's own mod for the game (offered or not), for the method advice and the notes.
+ public CatalogLumaMod? ModOf(GameInfo game)
+ {
+ var key = GameMatcher.Normalize(game.Name);
+ return key.Length > 0 ? _mods.FirstOrDefault(m => m.Key == key).Mod : null;
+ }
+
+ ///
+ /// The mod the user picked by hand. Soft rules (not DirectX 11, Unreal 5, blocked on the wiki) become notes; bitness and a missing
+ /// download are facts and still refuse.
+ ///
+ private LumaChoice ByHand(GameInfo game, string id, int bits)
+ {
+ if (id.Equals(UnrealId, StringComparison.OrdinalIgnoreCase))
+ {
+ var key = GameMatcher.Normalize(game.Name);
+ var row = key.Length > 0 ? _unreal.FirstOrDefault(g => g.Key == key).Game : null;
+ return Unreal(game, bits, row, "You picked it by hand. ", hand: true);
+ }
+ if (_catalog.Mods.FirstOrDefault(m => m.Id.Equals(id, StringComparison.OrdinalIgnoreCase)) is { } mod)
+ return Named(mod, "your pick", bits);
+ return None($"The Luma mod you picked ({id}) isn't in the catalog now. Reset to auto or pick another.");
+ }
+
+ /// The mods the user may pick by hand: every mod with a download, and the generic Unreal one.
+ public IReadOnlyList<(string Id, string Name)> ManualChoices()
+ {
+ var list = _catalog.Mods.Where(m => _catalog.PackageOf(m.Package)?.Files.Any(f => f.IsInstallable) == true).Select(m => (m.Id, m.Name)).ToList();
+ if (_catalog.PackageOf(_catalog.UnrealPackage)?.Files.Any(f => f.IsInstallable) == true) list.Add((UnrealId, "Luma for Unreal Engine (generic)"));
+ return list.DistinctBy(c => c.Item1, StringComparer.OrdinalIgnoreCase).OrderBy(c => c.Item2, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ private LumaChoice Named(CatalogLumaMod mod, string how, int bits)
+ {
+ var notes = new List();
+ if (mod.Status == "in-progress") notes.Add("The mod is marked in progress on the Luma wiki.");
+ if (mod.Notes is { Length: > 0 } n) notes.Add(n.Replace('\n', ' '));
+ var link = mod.Links?.Discussion ?? mod.Links?.Nexus ?? mod.Links?.Discord ?? mod.FeaturesUrl;
+ var dg = DgVoodoo(mod);
+ if (_catalog.PackageOf(mod.Package) is not { } package)
+ return None($"{mod.Name} has a Luma mod, but it isn't a release zip" + (link is null ? "." : "; get it from the author's page."), mod, link);
+ var file = package.FileFor(bits);
+ if (file is null)
+ return None($"{mod.Name}: the catalog has no {(bits == 32 ? "32-bit" : "64-bit")} Luma build for this game.", mod, link);
+ return new LumaChoice
+ {
+ Kind = LumaKind.Named, ModId = mod.Id, Title = mod.Name, Mod = mod, File = file, PackageId = package.Id, Version = _catalog.Release,
+ Reason = $"{mod.Name} has its own Luma mod (matched by {how}).",
+ Link = link, Notes = notes, NeedsDgVoodoo = mod.RequiresDgVoodoo, DgVoodooLines = dg,
+ };
+ }
+
+ private LumaChoice Unreal(GameInfo game, int bits, CatalogLumaUnrealGame? row, string why, bool hand)
+ {
+ var tech = game.Effective;
+ if (bits == 32) return None(why + "The generic Unreal mod is 64-bit only; this game is 32-bit.");
+ if ((!tech.IsUnreal || tech.Engine == GameEngine.UnrealLegacy) && !hand)
+ return None(why + "This isn't an Unreal Engine 4 or 5 game.");
+ var package = _catalog.PackageOf(_catalog.UnrealPackage);
+ var file = package?.FileFor(64);
+ if (file is null) return None(why + "The catalog has no generic Unreal Luma build right now.");
+
+ var notes = new List();
+ var dx11 = tech.Apis.HasFlag(GraphicsApis.Dx11);
+ if (!dx11)
+ {
+ if (!hand) return None(why + "Luma's Unreal mod needs DirectX 11 and this game isn't detected as DirectX 11.");
+ notes.Add("Luma's Unreal mod needs DirectX 11 and this game isn't detected as DirectX 11.");
+ }
+ if (tech.UeMajor >= 5 && !(game.Manual?.Apis?.HasFlag(GraphicsApis.Dx11) ?? false))
+ {
+ if (!hand) return None(why + "This is an Unreal Engine 5 game; the generic mod targets Unreal 4 with DirectX 11 (set the game's API to DirectX 11 by hand to try it).");
+ notes.Add("This is an Unreal Engine 5 game; the generic mod targets Unreal 4 with DirectX 11.");
+ }
+ if (row is { Blocked: true })
+ {
+ if (!hand) return None(why + "The wiki lists this game as blocked (no DLSS/FSR support and no HDR).");
+ notes.Add("The wiki lists this game as blocked: the generic mod does nothing useful there.");
+ }
+ if (row is null) notes.Add("The game isn't on the wiki's generic Unreal table: untested.");
+ if (row?.Notes is { Length: > 0 } n) notes.Add(n.Replace('\n', ' '));
+ if (row?.UeVersion is { Length: > 0 } ue) notes.Add($"Unreal version on the wiki: {ue}.");
+ var args = row?.LaunchArgs ?? [];
+ if (args.Count == 0) args = ["-dx11"];
+ return new LumaChoice
+ {
+ Kind = LumaKind.Unreal, ModId = UnrealId, Title = "Luma for Unreal Engine", File = file, PackageId = package!.Id, Version = _catalog.Release,
+ UnrealGame = row, Reason = why + "Generic Luma for Unreal Engine (DirectX 11).", Link = "https://github.com/Filoppi/Luma-Framework",
+ Notes = notes, EngineIni = row?.EngineIni ?? [], LaunchArgs = args,
+ };
+ }
+
+ private static IReadOnlyList DgVoodoo(CatalogLumaMod mod)
+ {
+ if (!mod.RequiresDgVoodoo) return [];
+ var lines = new List
+ {
+ "Needs dgVoodoo2" + (mod.DgVoodooVersions.Count > 0 ? $" ({string.Join(", ", mod.DgVoodooVersions)})" : "") +
+ ". The app doesn't download it: get it from github.com/dege-diosg/dgVoodoo2/releases and set it up as the wiki says.",
+ };
+ lines.AddRange(mod.DgVoodooSetup);
+ return lines;
+ }
+
+ private static LumaChoice None(string reason, CatalogLumaMod? mod = null, string? link = null) =>
+ new() { Kind = LumaKind.None, Reason = reason, Mod = mod, Title = mod?.Name ?? "", Link = link };
+}
+
+///
+/// A game's own pick of a Luma build (design.md §2 "Per-game pick"): one of the library's builds of the package the game is picked for.
+/// Without one the game follows the catalog's current file.
+///
+public static class LumaPin
+{
+ public static IReadOnlyList Candidates(LumaChoice choice, IEnumerable library)
+ {
+ if (!choice.IsOffered || choice.PackageId is null) return [];
+ return library
+ .Where(b => b.PackageId.Equals(choice.PackageId, StringComparison.OrdinalIgnoreCase) && b.Arch.Equals(choice.File!.Arch, StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(b => b.Date)
+ .ToList();
+ }
+
+ /// The choice with the pinned build in place of the catalog's; a pick that doesn't fit is ignored, so the game follows the catalog again.
+ public static LumaChoice Apply(LumaChoice choice, string? pin, IEnumerable library)
+ {
+ if (pin is null || Candidates(choice, library).FirstOrDefault(b => b.Id.Equals(pin, StringComparison.OrdinalIgnoreCase)) is not { } build
+ || !File.Exists(ComponentStore.LumaPathOf(build)))
+ return choice;
+ var file = new CatalogBinary { Name = build.FileName, Arch = build.Arch, Url = build.Url ?? "", Sha256 = build.Id, Version = build.Version, FirstSeen = build.FirstSeen };
+ return choice with { File = file, Pinned = build, Version = build.Version, Reason = choice.Reason + $" You pinned {build.Version}." };
+ }
+}
diff --git a/src/DLSSUpdater/Core/MethodAdvisor.cs b/src/DLSSUpdater/Core/MethodAdvisor.cs
index e326b8b..cb017ef 100644
--- a/src/DLSSUpdater/Core/MethodAdvisor.cs
+++ b/src/DLSSUpdater/Core/MethodAdvisor.cs
@@ -26,7 +26,7 @@ public enum Method
Dx11Bridge,
/// G, not in the app yet: a third-party upscaler mod for this game (REFramework PDUpscaler, ...).
UpscalerMod,
- /// G, not in the app yet: a Luma framework mod.
+ /// G: a Luma framework mod, installed from the Luma section of the game page (never picked for you, and not to be stacked with a RenoDX HDR mod).
Luma,
/// J, not in the app yet: a standalone all-in-one add-on.
AioAddon,
@@ -88,6 +88,8 @@ public sealed record AdvisorInput(GameInfo Game, GpuInfo Gpu)
public bool HudfixIncompatible { get; init; }
/// The game's entry on RenoDX's list, when the catalog has one.
public CatalogMod? RenoDxMod { get; init; }
+ /// What Luma would install in the game (the catalog's mod or the generic Unreal one); null or not offered = the Luma catalog has nothing for it.
+ public LumaChoice? Luma { get; init; }
/// Component ids with a build in the app's library that the user imported: the NR runtime and the NR add-ons.
public IReadOnlySet Imported { get; init; } = new HashSet();
/// The newest Deep Fried Chicken build runs in games without their own DLSS (4.x or newer).
@@ -266,7 +268,7 @@ private static void Shape(Ctx c, List rows, List list)
rows.Add(10);
list.Add(Feed(c, c.Is32 ? "beta" : null, "D3D9 and older games are translated to D3D11 first with dgVoodoo2 (freeware, not redistributable: you add it, Feeder's README has the settings); best motion vectors of all APIs.",
"dgVoodoo2 placed by hand (D3D9.dll and dgVoodoo.conf beside the exe)"));
- list.Add(Opt(Method.Luma, Availability.Coming, "Some DX9 games have a Luma mod (Borderlands 2 and Pre-Sequel, BioShock) that needs dgVoodoo2 too; the app doesn't install Luma yet."));
+ if (LumaOffered(c) is { } luma) list.Add(luma);
return;
}
if (api == GraphicsApis.Dx10)
@@ -318,7 +320,7 @@ private static void Shape(Ctx c, List rows, List list)
}
if (c.In.OptiCompat is { List: "lumaUe" } && c.Api == GraphicsApis.Dx11)
{
- list.Add(Opt(Method.Luma, Availability.Coming, "Luma UE replaces this DX11 Unreal game's TAA with DLSS or DLAA (it is on OptiScaler's Luma UE list); the app doesn't install Luma yet."));
+ list.Add(LumaOffered(c) ?? NoLumaDownload("Luma UE replaces this DX11 Unreal game's TAA with DLSS or DLAA (it is on OptiScaler's Luma UE list)"));
list.Add(Opti(c, "OptiScaler on top of what Luma UE adds."));
}
list.Add(Feed(c, null, "Unreal game without an upscaler plugin: Feeder + Deep Fried Chicken adds NR. " + FeederCaveat, null));
@@ -402,13 +404,20 @@ private static void AddExtras(Ctx c, List rows, List list)
$"{GraphicsApiInfo.Label(c.In.Game.Effective.PrimaryApi)} game with no HDR mod of its own: DXVK runs it on Vulkan, and the Lilium HDR build can output scRGB HDR (start with the Safest preset). Install it from the DXVK section on this page; it is never picked for you.");
list.Add(c.In.Game.AntiCheat is { } ac ? dxvk with { Warning = AntiCheatCaution(ac) } : dxvk);
}
- // Row 16: OptiScaler's list also names the games that run a Luma mod; the Luma wiki itself isn't in the catalog yet.
- if (c.In.OptiCompat is { } listed && list.All(m => m.Method != Method.Luma)
- && (listed.List == "lumaUe" || (listed.List == "upscalerMods" && listed.Notes?.Contains("Luma", StringComparison.OrdinalIgnoreCase) == true)))
+ // Row 16: a Luma mod the app can install, or a game OptiScaler's list shows running one that the Luma catalog has no download for.
+ if (list.All(m => m.Method != Method.Luma))
{
- rows.Add(16);
- list.Add(Opt(Method.Luma, Availability.Coming,
- "OptiScaler's list shows this game running a Luma framework mod (DX11 only). Don't stack it with a RenoDX HDR mod; the app doesn't install Luma yet."));
+ if (LumaOffered(c) is { } luma)
+ {
+ rows.Add(16);
+ list.Add(luma);
+ }
+ else if (c.In.OptiCompat is { } listed
+ && (listed.List == "lumaUe" || (listed.List == "upscalerMods" && listed.Notes?.Contains("Luma", StringComparison.OrdinalIgnoreCase) == true)))
+ {
+ rows.Add(16);
+ list.Add(NoLumaDownload("OptiScaler's list shows this game running a Luma framework mod (DX11 only)"));
+ }
}
if (c.In.HudfixIncompatible && list.Any(m => m.Method is Method.OptiScaler or Method.OptiScalerNr))
{
@@ -442,6 +451,16 @@ private static void AddExtras(Ctx c, List rows, List list)
// ---------- option builders ----------
+ /// Luma is advice only (installed from its own section, never picked for you): the game has a mod, or the generic Unreal one is listed for it.
+ private static MethodOption? LumaOffered(Ctx c) => c.In.Luma is { IsOffered: true } l
+ ? Opt(Method.Luma, Availability.Available,
+ $"Luma has a mod for this game ({l.Title}); install it from the Luma section on this page. Don't stack it with a RenoDX HDR mod (the app asks before it lets you)." +
+ (l.NeedsDgVoodoo ? " It needs dgVoodoo2, which you add yourself (the section lists the setup)." : ""))
+ : null;
+
+ private static MethodOption NoLumaDownload(string what) =>
+ Opt(Method.Luma, Availability.NotPossible, $"{what}, but the Luma catalog has no download for this game that the app can place. Don't stack a Luma mod with a RenoDX HDR mod.");
+
private static MethodOption Opt(Method m, Availability a, string reason, IReadOnlyList? components = null,
IReadOnlyList? needs = null, InstallMode? mode = null, bool unverified = false) =>
new(m, TitleOf(m), a, reason, components ?? [], needs ?? [], mode, unverified);
diff --git a/src/DLSSUpdater/MainWindow.xaml b/src/DLSSUpdater/MainWindow.xaml
index 863b318..c289a78 100644
--- a/src/DLSSUpdater/MainWindow.xaml
+++ b/src/DLSSUpdater/MainWindow.xaml
@@ -1292,6 +1292,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2225,6 +2313,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+