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 @@ + + + + + +