diff --git a/PluginTest/Program.cs b/PluginTest/Program.cs index 40292d2..4df6f28 100644 --- a/PluginTest/Program.cs +++ b/PluginTest/Program.cs @@ -23,6 +23,13 @@ private static async Task Main(string[] args) args = [..args.Where(x => !x.Equals("NoWait", StringComparison.OrdinalIgnoreCase))]; + if (args.Contains("GameSettingsContract", StringComparer.OrdinalIgnoreCase)) + { + Test.TestGameSettingsContract(); + Console.WriteLine("Game settings contract passed!"); + return 0; + } + foreach (var arg in args) { var result = await TryPerformLibraryTest(arg); @@ -144,7 +151,7 @@ private static bool LogInvokeTest(nint libraryHandle, string entryPointName, } private static void PrintHelp() - => Console.WriteLine($"Usage:\r\n{Path.GetFileName(Environment.ProcessPath)} Path_to_dll_1 Path_to_dll_2 ..."); + => Console.WriteLine($"Usage:\r\n{Path.GetFileName(Environment.ProcessPath)} Path_to_dll_1 Path_to_dll_2 ...\r\n{Path.GetFileName(Environment.ProcessPath)} GameSettingsContract"); } public class InvokeLogger : ILogger diff --git a/PluginTest/Test.GameSettings.cs b/PluginTest/Test.GameSettings.cs new file mode 100644 index 0000000..ad489e0 --- /dev/null +++ b/PluginTest/Test.GameSettings.cs @@ -0,0 +1,31 @@ +using Hi3Helper.Plugin.Core.UI.Settings; +using System; + +namespace PluginTest; + +internal static partial class Test +{ + internal static void TestGameSettingsContract() + { + GameSettingsPage source = new([ + new GameSettingsSection("General", [ + GameSettingEntry.Toggle("enabled", "Enabled", true), + GameSettingEntry.Text("name", "Name", "Collapse"), + GameSettingEntry.Number("count", "Count", 3, 0, 10), + GameSettingEntry.Slider("volume", "Volume", 75, 0, 100), + GameSettingEntry.Choice("language", "Language", "en", [ + new GameSettingChoice("en", "English") + ]) + ]) + ]) { Title = "Settings" }; + + string json = GameSettingsPageSerializer.Serialize(source); + GameSettingsPage result = GameSettingsPageSerializer.Deserialize(json) + ?? throw new InvalidOperationException("Deserialization returned null"); + + if (result.Title != source.Title || result.Sections.Count != 1 || result.Sections[0].Entries.Count != 5) + { + throw new InvalidOperationException("The game settings contract did not round-trip"); + } + } +} diff --git a/README.md b/README.md index 64b0fea..c8ad6e7 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ Make sure that your code is **as reflection-free** as possible, as the code is e # What's Included? This Core Library includes various APIs to make the plugin development faster, without needing to implement the entire functions from scratch. Here's a list of what's included currently: -### V1 (v0.1.5.0) Implementation Standard +### V1 (v0.1.6.0) Implementation Standard -This repository follows the V1 implementation standard (current library version: ``v0.1.5.0``). The standard collects base API contracts, COM interop helpers, marshallers and small utility primitives that plugin authors and the launcher can rely on. +This repository follows the V1 implementation standard (current library version: ``v0.1.6.0``). The standard collects base API contracts, COM interop helpers, marshallers and small utility primitives that plugin authors and the launcher can rely on. > [!WARNING] > The API contracts and implementations are still under development, so expect some breaking changes in the future. @@ -95,4 +95,4 @@ Quick-start links: To see the example of how the plugin implemented using this Core Library, check the link below: * [Hi3Helper.Plugin.HBR](https://github.com/CollapseLauncher/Hi3Helper.Plugin.HBR) (A basic plugin implementation for Game: [Heaven Burns Red](https://heavenburnsred.yo-star.com/) by [Key](https://key.visualarts.gr.jp/)) * [Hi3Helper.Plugin.Wuwa](https://github.com/CollapseLauncher/Hi3Helper.Plugin.Wuwa) (A plugin implementation for [Wuthering Waves](https://wutheringwaves.kurogames.com/en/main) by [Kuro Games](https://kurogames.com)) -* [Hi3Helper.Plugin.DNA](https://github.com/CollapseLauncher/Hi3Helper.Plugin.DNA) (A plugin implementation for [Duet Night Abyss](https://duetnightabyss.dna-panstudio.com//) by [Hero Games](https://herogame.com/)) \ No newline at end of file +* [Hi3Helper.Plugin.DNA](https://github.com/CollapseLauncher/Hi3Helper.Plugin.DNA) (A plugin implementation for [Duet Night Abyss](https://duetnightabyss.dna-panstudio.com//) by [Hero Games](https://herogame.com/)) diff --git a/SharedStatic.V1Ext.cs b/SharedStatic.V1Ext.cs index e3cc036..9ace8c1 100644 --- a/SharedStatic.V1Ext.cs +++ b/SharedStatic.V1Ext.cs @@ -26,6 +26,7 @@ static SharedStaticV1Ext() InitExtension_Update3Exports(); InitExtension_Update4Exports(); InitExtension_Update5Exports(); + InitExtension_Update6Exports(); } /// diff --git a/SharedStatic.V1Ext_Update6.cs b/SharedStatic.V1Ext_Update6.cs new file mode 100644 index 0000000..6b9da9e --- /dev/null +++ b/SharedStatic.V1Ext_Update6.cs @@ -0,0 +1,117 @@ +using Hi3Helper.Plugin.Core.Management.PresetConfig; +using Hi3Helper.Plugin.Core.UI.Settings; +using Hi3Helper.Plugin.Core.Utility; +using Microsoft.Extensions.Logging; +using System; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +namespace Hi3Helper.Plugin.Core; + +public partial class SharedStaticV1Ext +{ + internal unsafe delegate HResult GetGameSettingsPageDelegate(nint presetConfig, + out PluginDisposableMemoryMarshal pageJson); + internal unsafe delegate HResult SetGameSettingValueDelegate(nint presetConfig, + char* key, int keyLength, + char* value, int valueLength); + internal delegate HResult ApplyGameSettingsDelegate(nint presetConfig); +} + +public partial class SharedStaticV1Ext +{ + private static unsafe void InitExtension_Update6Exports() + { + TryRegisterApiExport("GetGameSettingsPage", GetGameSettingsPage); + TryRegisterApiExport("SetGameSettingValue", SetGameSettingValue); + TryRegisterApiExport("ApplyGameSettings", ApplyGameSettings); + } + + private static unsafe HResult GetGameSettingsPage(nint presetConfigP, + out PluginDisposableMemoryMarshal pageJson) + { + pageJson = PluginDisposableMemoryMarshal.Empty; + try + { + IPluginPresetConfig presetConfig = GetPresetConfig(presetConfigP); + GameSettingsPage? page = ThisExtensionExport.GetGameSettingsPageCore(presetConfig); + if (page == null) + { + return HResult.False; + } + + pageJson = GameSettingsPageSerializer.Serialize(page); + return HResult.Ok; + } + catch (Exception ex) + { + InstanceLogger.LogError(ex, "An error occurred while retrieving the plugin game settings page"); + return Marshal.GetHRForException(ex); + } + } + + private static unsafe HResult SetGameSettingValue(nint presetConfigP, + char* key, int keyLength, + char* value, int valueLength) + { + try + { + IPluginPresetConfig presetConfig = GetPresetConfig(presetConfigP); + string keyString = new(key, 0, keyLength); + string valueString = new(value, 0, valueLength); + ThisExtensionExport.SetGameSettingValueCore(presetConfig, keyString, valueString); + return HResult.Ok; + } + catch (Exception ex) + { + InstanceLogger.LogError(ex, "An error occurred while updating plugin game setting {Key}", + key == null ? null : new string(key, 0, keyLength)); + return Marshal.GetHRForException(ex); + } + } + + private static HResult ApplyGameSettings(nint presetConfigP) + { + try + { + ThisExtensionExport.ApplyGameSettingsCore(GetPresetConfig(presetConfigP)); + return HResult.Ok; + } + catch (Exception ex) + { + InstanceLogger.LogError(ex, "An error occurred while applying plugin game settings"); + return Marshal.GetHRForException(ex); + } + } + + private static unsafe IPluginPresetConfig GetPresetConfig(nint presetConfigP) + { + if (presetConfigP == nint.Zero) + { + throw new ArgumentNullException(nameof(presetConfigP)); + } + +#if MANUALCOM + return ComWrappers.ComInterfaceDispatch.GetInstance( + (ComWrappers.ComInterfaceDispatch*)presetConfigP); +#else + return ComInterfaceMarshaller.ConvertToManaged((void*)presetConfigP) + ?? throw new InvalidCastException("Cannot convert the preset config pointer to IPluginPresetConfig"); +#endif + } + + /// + /// Returns the declarative settings page for a game preset, or null when the preset has no settings page. + /// + protected virtual GameSettingsPage? GetGameSettingsPageCore(IPluginPresetConfig presetConfig) => null; + + /// + /// Receives a value edited by the user. Values use invariant strings; conversion and validation belong to the plugin. + /// + protected virtual void SetGameSettingValueCore(IPluginPresetConfig presetConfig, string key, string value) { } + + /// + /// Persists the edited settings for the specified game preset. + /// + protected virtual void ApplyGameSettingsCore(IPluginPresetConfig presetConfig) { } +} diff --git a/SharedStatic.cs b/SharedStatic.cs index 11e9f14..725ee89 100644 --- a/SharedStatic.cs +++ b/SharedStatic.cs @@ -103,7 +103,7 @@ static unsafe SharedStatic() internal static string? ProxyPassword; public static string PluginLocaleCode { get; internal set; } = "en-us"; - public static readonly GameVersion LibraryStandardVersion = new(0, 1, 5, 0); + public static readonly GameVersion LibraryStandardVersion = new(0, 1, 6, 0); public static readonly ILogger InstanceLogger = new SharedLogger(); #if DEBUG diff --git a/UI/Settings/GameSettingChoice.cs b/UI/Settings/GameSettingChoice.cs new file mode 100644 index 0000000..0359b44 --- /dev/null +++ b/UI/Settings/GameSettingChoice.cs @@ -0,0 +1,10 @@ +namespace Hi3Helper.Plugin.Core.UI.Settings; + +/// +/// Defines one selectable value for a setting. +/// +public sealed class GameSettingChoice(string value, string title) +{ + public string Value { get; init; } = value; + public string Title { get; init; } = title; +} diff --git a/UI/Settings/GameSettingEntry.cs b/UI/Settings/GameSettingEntry.cs new file mode 100644 index 0000000..5b9c883 --- /dev/null +++ b/UI/Settings/GameSettingEntry.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Hi3Helper.Plugin.Core.UI.Settings; + +/// +/// Defines one editable setting in a plugin-provided game settings page. +/// +public sealed class GameSettingEntry +{ + public required string Key { get; init; } + public required string Title { get; init; } + public string? Description { get; init; } + public required GameSettingKind Kind { get; init; } + public required string Value { get; init; } + public string? Placeholder { get; init; } + public double Minimum { get; init; } + public double Maximum { get; init; } = 100; + public double Step { get; init; } = 1; + public IReadOnlyList? Choices { get; init; } + + public static GameSettingEntry Toggle(string key, string title, bool value, string? description = null) => + new() + { + Key = key, + Title = title, + Description = description, + Kind = GameSettingKind.Toggle, + Value = value ? bool.TrueString : bool.FalseString + }; + + public static GameSettingEntry Text(string key, string title, string? value = null, string? description = null, + string? placeholder = null) => + new() + { + Key = key, + Title = title, + Description = description, + Kind = GameSettingKind.Text, + Value = value ?? string.Empty, + Placeholder = placeholder + }; + + public static GameSettingEntry Number(string key, string title, double value, double minimum = double.MinValue, + double maximum = double.MaxValue, double step = 1, + string? description = null) => + Numeric(key, title, value, minimum, maximum, step, description, GameSettingKind.Number); + + public static GameSettingEntry Slider(string key, string title, double value, double minimum, double maximum, + double step = 1, string? description = null) => + Numeric(key, title, value, minimum, maximum, step, description, GameSettingKind.Slider); + + public static GameSettingEntry Choice(string key, string title, string value, + IReadOnlyList choices, + string? description = null) => + new() + { + Key = key, + Title = title, + Description = description, + Kind = GameSettingKind.Choice, + Value = value, + Choices = choices + }; + + private static GameSettingEntry Numeric(string key, string title, double value, double minimum, double maximum, + double step, string? description, GameSettingKind kind) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(minimum, maximum); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(step); + + return new GameSettingEntry + { + Key = key, + Title = title, + Description = description, + Kind = kind, + Value = value.ToString(CultureInfo.InvariantCulture), + Minimum = minimum, + Maximum = maximum, + Step = step + }; + } +} diff --git a/UI/Settings/GameSettingKind.cs b/UI/Settings/GameSettingKind.cs new file mode 100644 index 0000000..853dfe6 --- /dev/null +++ b/UI/Settings/GameSettingKind.cs @@ -0,0 +1,13 @@ +namespace Hi3Helper.Plugin.Core.UI.Settings; + +/// +/// Identifies the launcher control used to edit a game setting. +/// +public enum GameSettingKind +{ + Toggle, + Text, + Number, + Slider, + Choice +} diff --git a/UI/Settings/GameSettingsPage.cs b/UI/Settings/GameSettingsPage.cs new file mode 100644 index 0000000..c9fbfd8 --- /dev/null +++ b/UI/Settings/GameSettingsPage.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace Hi3Helper.Plugin.Core.UI.Settings; + +/// +/// Declaratively describes a game settings page rendered by the launcher. +/// +public sealed class GameSettingsPage(IReadOnlyList sections) +{ + public string? Title { get; init; } + public IReadOnlyList Sections { get; init; } = sections; +} diff --git a/UI/Settings/GameSettingsPageSerializer.cs b/UI/Settings/GameSettingsPageSerializer.cs new file mode 100644 index 0000000..abb9744 --- /dev/null +++ b/UI/Settings/GameSettingsPageSerializer.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Hi3Helper.Plugin.Core.UI.Settings; + +/// +/// Serializes the declarative settings contract passed across the plugin ABI. +/// +public static class GameSettingsPageSerializer +{ + public static string Serialize(GameSettingsPage page) => + JsonSerializer.Serialize(page, GameSettingsPageJsonContext.Default.GameSettingsPage); + + public static GameSettingsPage? Deserialize(string json) => + JsonSerializer.Deserialize(json, GameSettingsPageJsonContext.Default.GameSettingsPage); +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(GameSettingsPage))] +internal sealed partial class GameSettingsPageJsonContext : JsonSerializerContext; diff --git a/UI/Settings/GameSettingsSection.cs b/UI/Settings/GameSettingsSection.cs new file mode 100644 index 0000000..a84ee0e --- /dev/null +++ b/UI/Settings/GameSettingsSection.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Hi3Helper.Plugin.Core.UI.Settings; + +/// +/// Groups related entries on a plugin-provided game settings page. +/// +public sealed class GameSettingsSection(string title, IReadOnlyList entries) +{ + public string Title { get; init; } = title; + public string? Description { get; init; } + public IReadOnlyList Entries { get; init; } = entries; +} diff --git a/Utility/GameSettingsExtension.cs b/Utility/GameSettingsExtension.cs new file mode 100644 index 0000000..79bf79d --- /dev/null +++ b/Utility/GameSettingsExtension.cs @@ -0,0 +1,115 @@ +using Hi3Helper.Plugin.Core.Management.PresetConfig; +using Hi3Helper.Plugin.Core.UI.Settings; +using System; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +namespace Hi3Helper.Plugin.Core.Utility; + +/// +/// Provides launcher-side access to the optional v0.1.6 game settings exports. +/// +public static class GameSettingsExtension +{ + public sealed class GameSettingsContext + { + private readonly SharedStaticV1Ext.GetGameSettingsPageDelegate? _getPage; + private readonly SharedStaticV1Ext.SetGameSettingValueDelegate? _setValue; + private readonly SharedStaticV1Ext.ApplyGameSettingsDelegate? _apply; + + public IPluginPresetConfig PresetConfig { get; } + public bool IsFeatureAvailable => _getPage != null && _setValue != null && _apply != null; + public bool HasPage => TryGetPage(out _, out _); + + public GameSettingsContext(nint pluginHandle, IPluginPresetConfig presetConfig) + { + PresetConfig = presetConfig; + pluginHandle.TryGetExport("GetGameSettingsPage", out SharedStaticV1Ext.GetGameSettingsPageDelegate getPage); + pluginHandle.TryGetExport("SetGameSettingValue", out SharedStaticV1Ext.SetGameSettingValueDelegate setValue); + pluginHandle.TryGetExport("ApplyGameSettings", out SharedStaticV1Ext.ApplyGameSettingsDelegate apply); + _getPage = getPage; + _setValue = setValue; + _apply = apply; + } + + public unsafe bool TryGetPage(out GameSettingsPage? page, out Exception? error) + { + page = null; + error = null; + if (!IsFeatureAvailable) + { + return false; + } + + nint presetConfigP = (nint)ComInterfaceMarshaller.ConvertToUnmanaged(PresetConfig); + try + { + int hResult = _getPage!(presetConfigP, out PluginDisposableMemoryMarshal pageJson); + if (hResult != 0) + { + error = Marshal.GetExceptionForHR(hResult); + return false; + } + + string? json = pageJson; + if (string.IsNullOrWhiteSpace(json)) + { + return false; + } + + page = GameSettingsPageSerializer.Deserialize(json); + return page != null; + } + catch (Exception ex) + { + error = ex; + return false; + } + finally + { + Marshal.Release(presetConfigP); + } + } + + public unsafe void SetValue(string key, string value) + { + if (_setValue == null) + { + throw new NotSupportedException("The plugin does not expose game settings"); + } + + nint presetConfigP = (nint)ComInterfaceMarshaller.ConvertToUnmanaged(PresetConfig); + try + { + fixed (char* keyP = key) + fixed (char* valueP = value) + { + int hResult = _setValue(presetConfigP, keyP, key.Length, valueP, value.Length); + Marshal.ThrowExceptionForHR(hResult); + } + } + finally + { + Marshal.Release(presetConfigP); + } + } + + public unsafe void Apply() + { + if (_apply == null) + { + throw new NotSupportedException("The plugin does not expose game settings"); + } + + nint presetConfigP = (nint)ComInterfaceMarshaller.ConvertToUnmanaged(PresetConfig); + try + { + Marshal.ThrowExceptionForHR(_apply(presetConfigP)); + } + finally + { + Marshal.Release(presetConfigP); + } + } + } +} diff --git a/docs/advanced.md b/docs/advanced.md index 686cb62..e3fcf34 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -286,3 +286,66 @@ Offset Field Type Description - If the plugin exports `SetPerFileProgressCallback`, Collapse registers its handler on startup and shows per-file progress in the install UI. - If the plugin **does not** export the callback (i.e. older plugin versions), Collapse falls back to mirroring the aggregate `InstallProgressDelegate` as an approximation of per-file progress. + +--- + +## 6. Game settings pages + +The `v0.1-update6` extension lets a plugin describe a native game settings page without taking a dependency on WinUI. Collapse renders the page using its own controls and styling. The supported entry kinds are toggle, text, number, slider, and choice. + +Override the three game-settings methods on your `SharedStaticV1Ext` implementation: + +```csharp +using Hi3Helper.Plugin.Core; +using Hi3Helper.Plugin.Core.Management.PresetConfig; +using Hi3Helper.Plugin.Core.UI.Settings; +using System; +using System.Globalization; + +public sealed class PluginExports : SharedStaticV1Ext +{ + private bool _fullscreen = true; + private double _volume = 80; + private string _language = "en"; + + protected override GameSettingsPage? GetGameSettingsPageCore(IPluginPresetConfig presetConfig) => + new([ + new GameSettingsSection("Display", [ + GameSettingEntry.Toggle("fullscreen", "Fullscreen", _fullscreen), + GameSettingEntry.Slider("volume", "Volume", _volume, 0, 100, 1), + GameSettingEntry.Choice("language", "Language", _language, [ + new GameSettingChoice("en", "English"), + new GameSettingChoice("ja", "Japanese") + ]) + ]) + ]) { Title = "Game settings" }; + + protected override void SetGameSettingValueCore(IPluginPresetConfig presetConfig, + string key, string value) + { + switch (key) + { + case "fullscreen": + _fullscreen = bool.Parse(value); + break; + case "volume": + _volume = double.Parse(value, CultureInfo.InvariantCulture); + break; + case "language": + _language = value; + break; + default: + throw new ArgumentOutOfRangeException(nameof(key)); + } + } + + protected override void ApplyGameSettingsCore(IPluginPresetConfig presetConfig) + { + // Validate and persist the values received by SetGameSettingValueCore. + } +} +``` + +Each entry must have a key unique within the page. Collapse sends booleans and numbers as invariant strings. `SetGameSettingValueCore` should update pending plugin state; persist that state in `ApplyGameSettingsCore`. Throwing from either callback returns the error to the launcher and displays it on the page. + +The page is optional per preset: return `null` from `GetGameSettingsPageCore` when the selected preset has no game settings. Older plugins do not export the update6 functions, so Collapse keeps their Game Settings navigation item hidden. diff --git a/docs/introduction.md b/docs/introduction.md index 35be950..6153b39 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -38,7 +38,7 @@ Plugin.dll (NativeAOT) ## API standard versioning -The current API standard version is **v0.1.5**. All plugins must implement at minimum the **v0.1 core** exports. Optional feature sets are versioned as update packages (`v0.1-update1`, `v0.1-update2`, etc.) and are handled automatically by `SharedStaticV1Ext`. +The current API standard version is **v0.1.6**. All plugins must implement at minimum the **v0.1 core** exports. Optional feature sets are versioned as update packages (`v0.1-update1`, `v0.1-update2`, etc.) and are handled automatically by `SharedStaticV1Ext`. | Update | Export(s) added | Description | |--------|----------------|-------------| @@ -47,6 +47,7 @@ The current API standard version is **v0.1.5**. All plugins must implement at mi | v0.1-update3 | `StartResizableWindowHookAsync` | Resizable window hook | | v0.1-update4 | `RegisterSpeedThrottlerService` | Download speed throttling | | v0.1-update5 | `SetPerFileProgressCallback` | Per-file install/download progress reporting | +| v0.1-update6 | `GetGameSettingsPage`, `SetGameSettingValue`, `ApplyGameSettings` | Declarative, launcher-rendered game settings pages | ## Next steps diff --git a/index.md b/index.md index bb2683b..74119d7 100644 --- a/index.md +++ b/index.md @@ -11,7 +11,7 @@ _layout: landing - **Full NativeAOT support** — designed to be compiled with .NET NativeAOT; reflection-free when targeting `MANUALCOM`/`USELIGHTWEIGHTJSONPARSER` configurations - **COM interop via `System.Runtime.InteropServices.Marshalling`** — no custom COM registration; the launcher discovers everything through a single `GetApiExport` entry point - **Batteries-included utilities** — pre-configured `HttpClient` builder with automatic proxy and DNS resolver integration, retry-able download streams, speed limiting, and unmanaged memory helpers -- **Versioned extension API** — optional feature sets (`v0.1-update1` through `v0.1-update5`) for game launch, Discord Rich Presence, resizable-window hook, download throttling, and per-file install progress are all opt-in +- **Versioned extension API** — optional feature sets (`v0.1-update1` through `v0.1-update6`) for game launch, Discord Rich Presence, resizable-window hook, download throttling, install progress, and declarative game settings are all opt-in ## Getting started @@ -28,4 +28,4 @@ _layout: landing - [Hi3Helper.Plugin.HBR](https://github.com/CollapseLauncher/Hi3Helper.Plugin.HBR) — Heaven Burns Red - [Hi3Helper.Plugin.Wuwa](https://github.com/CollapseLauncher/Hi3Helper.Plugin.Wuwa) — Wuthering Waves -- [Hi3Helper.Plugin.DNA](https://github.com/CollapseLauncher/Hi3Helper.Plugin.DNA) — Duet Night Abyss \ No newline at end of file +- [Hi3Helper.Plugin.DNA](https://github.com/CollapseLauncher/Hi3Helper.Plugin.DNA) — Duet Night Abyss