Compare commits

..

27 Commits

Author SHA1 Message Date
qhy040404
87c3043fd9 fix wrong titlebar color when manually manually setting the color theme 2024-03-12 22:34:32 +08:00
DismissedLight
762bc14b88 adjust ui for city banner 2024-03-12 21:35:06 +08:00
DismissedLight
51dfc7020f Merge pull request #1466 from DGP-Studio/dependabot/nuget/src/Snap.Hutao/develop/packages-ba3ce57765 2024-03-12 09:37:41 +08:00
dependabot[bot]
47dda1bebc Bump the packages group in /src/Snap.Hutao with 1 update
Bumps the packages group in /src/Snap.Hutao with 1 update: [MSTest.TestAdapter](https://github.com/microsoft/testfx).


Updates `MSTest.TestAdapter` from 3.2.1 to 3.2.2
- [Release notes](https://github.com/microsoft/testfx/releases)
- [Changelog](https://github.com/microsoft/testfx/blob/main/docs/Changelog.md)
- [Commits](https://github.com/microsoft/testfx/compare/v3.2.1...v3.2.2)

---
updated-dependencies:
- dependency-name: MSTest.TestAdapter
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: packages
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 07:12:02 +00:00
Lightczx
1a300e8b9c fix predownload latest path and name 2024-03-11 11:47:43 +08:00
Lightczx
16e8a17614 disable launchoptions by default 2024-03-11 11:24:41 +08:00
Lightczx
a5c75f9465 revert BGI process waitforinputidle 2024-03-11 11:16:23 +08:00
Lightczx
0cb59808a1 refactor unlocking fps 2024-03-11 11:01:57 +08:00
Lightczx
d632002e4b Sync Scighost/Starward#692 2024-03-11 09:33:21 +08:00
DismissedLight
cf6a972d55 Update FeedbackPage.xaml 2024-03-10 22:39:11 +08:00
DismissedLight
e1a976f02d fix theme switch dropping shadow 2024-03-10 22:28:03 +08:00
DismissedLight
dbedc2a00d fix equal panel spacing 2024-03-10 22:23:10 +08:00
DismissedLight
03312f1d52 Merge pull request #1464 from DGP-Studio/fix/horizontal_equal_panel 2024-03-10 21:37:03 +08:00
qhy040404
47f29de9c6 fix measure 2024-03-10 21:30:15 +08:00
Lightczx
2a5e7e0136 temp progress 2024-03-10 17:30:57 +08:00
Lightczx
177e7a6233 fix #1459 2024-03-10 14:29:57 +08:00
DismissedLight
bd09b303ab Merge pull request #1456 from DGP-Studio/fix/darkmode
fix #1434
2024-03-10 14:13:10 +08:00
qhy040404
5538b74939 remove WindowOptions.UseSystemBackdrop 2024-03-10 12:16:30 +08:00
qhy040404
fda289421b code style 2024-03-10 12:16:30 +08:00
qhy040404
c0abe7f7c5 fix #1434 2024-03-10 12:16:30 +08:00
DismissedLight
e95732d448 Merge pull request #1454 from DGP-Studio/fix/navigate_crash 2024-03-10 12:10:13 +08:00
DismissedLight
9e52a87d11 Merge pull request #1457 from DGP-Studio/fix/composition_image_delay 2024-03-10 12:03:29 +08:00
qhy040404
66bce570cc Update build.cake 2024-03-10 10:46:46 +08:00
qhy040404
a5091134b0 fix typo 2024-03-10 10:46:46 +08:00
qhy040404
59c9845ad0 fix navigation crash 2024-03-10 10:46:46 +08:00
qhy040404
2db829e1e2 add delay to fix blink again 2024-03-10 10:46:37 +08:00
DismissedLight
1a64328270 Update GachaTypeComparer.cs 2024-03-09 16:48:37 +08:00
46 changed files with 907 additions and 659 deletions

View File

@@ -69,6 +69,15 @@ else if (AppVeyor.IsRunningOnAppVeyor)
})[..^2];
Information($"Version: {version}");
}
else // Local
{
repoDir = System.Environment.CurrentDirectory;
outputPath = System.IO.Path.Combine(repoDir, "src", "output");
version = System.DateTime.Now.ToString("yyyy.M.d.") + ((int)((System.DateTime.Now - System.DateTime.Today).TotalSeconds / 86400 * 65535)).ToString();
Information($"Version: {version}");
}
Task("Build")
.IsDependentOn("Build binary package")
@@ -112,6 +121,17 @@ Task("Generate AppxManifest")
Information("Using Release configuration");
content = System.Text.RegularExpressions.Regex.Replace(content, " Publisher=\"([^\"]*)\"", " Publisher=\"CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US\"");
}
else
{
Information("Using Local configuration.");
content = content
.Replace("Snap Hutao", "Snap Hutao Local")
.Replace("胡桃", "胡桃 Local")
.Replace("DGP Studio", "DGP Studio CI");
content = System.Text.RegularExpressions.Regex.Replace(content, " Name=\"([^\"]*)\"", " Name=\"E8B6E2B3-D2A0-4435-A81D-2A16AAF405C7\"");
content = System.Text.RegularExpressions.Regex.Replace(content, " Publisher=\"([^\"]*)\"", " Publisher=\"E=admin@dgp-studio.cn, CN=DGP Studio CI, OU=CI, O=DGP-Studio, L=San Jose, S=CA, C=US\"");
content = System.Text.RegularExpressions.Regex.Replace(content, " Version=\"([0-9\\.]+)\"", $" Version=\"{version}\"");
}
System.IO.File.WriteAllText(manifest, content);
@@ -173,6 +193,10 @@ Task("Build MSIX")
{
arguments = "pack /d " + binPath + " /p " + System.IO.Path.Combine(outputPath, $"Snap.Hutao-{version}.msix");
}
else
{
arguments = "pack /d " + binPath + " /p " + System.IO.Path.Combine(outputPath, $"Snap.Hutao.Local-{version}.msix");
}
var p = StartProcess(
"makeappx.exe",
new ProcessSettings

View File

@@ -13,7 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="MSTest.TestAdapter" Version="3.2.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.2.2" />
<PackageReference Include="MSTest.TestFramework" Version="3.2.2" />
<PackageReference Include="coverlet.collector" Version="6.0.1">
<PrivateAssets>all</PrivateAssets>

View File

@@ -0,0 +1,58 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.UI.Xaml;
using Windows.Foundation;
namespace Snap.Hutao.Control.Panel;
[DependencyProperty("MinItemWidth", typeof(double))]
[DependencyProperty("Spacing", typeof(double))]
internal partial class HorizontalEqualPanel : Microsoft.UI.Xaml.Controls.Panel
{
public HorizontalEqualPanel()
{
Loaded += OnLoaded;
SizeChanged += OnSizeChanged;
}
protected override Size MeasureOverride(Size availableSize)
{
foreach (UIElement child in Children)
{
// ScrollViewer will always return an Infinity Size, we should use ActualWidth for this situation.
double availableWidth = double.IsInfinity(availableSize.Width) ? ActualWidth : availableSize.Width;
double childAvailableWidth = (availableWidth + Spacing) / Children.Count;
double childMaxAvailableWidth = Math.Max(MinItemWidth, childAvailableWidth);
child.Measure(new(childMaxAvailableWidth - Spacing, ActualHeight));
}
return base.MeasureOverride(availableSize);
}
protected override Size ArrangeOverride(Size finalSize)
{
int itemCount = Children.Count;
double availableWidthPerItem = (finalSize.Width - (Spacing * (itemCount - 1))) / itemCount;
double actualItemWidth = Math.Max(MinItemWidth, availableWidthPerItem);
double offset = 0;
foreach (UIElement child in Children)
{
child.Arrange(new Rect(offset, 0, actualItemWidth, finalSize.Height));
offset += actualItemWidth + Spacing;
}
return finalSize;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
MinWidth = (MinItemWidth * Children.Count) + (Spacing * (Children.Count - 1));
}
private void OnSizeChanged(object sender, SizeChangedEventArgs e)
{
InvalidateMeasure();
}
}

View File

@@ -4,20 +4,18 @@
xmlns:cwm="using:CommunityToolkit.WinUI.Media">
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<cwm:AttachedCardShadow
x:Key="CompatCardShadow"
BlurRadius="8"
Opacity="0.14"
Offset="0,4,0"/>
<x:Double x:Key="CompatShadowThemeOpacity">0.14</x:Double>
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<x:Double x:Key="CompatShadowThemeOpacity">0.28</x:Double>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<cwm:AttachedCardShadow
x:Key="CompatCardShadow"
BlurRadius="8"
Opacity="0.28"
Opacity="{ThemeResource CompatShadowThemeOpacity}"
Offset="0,4,0"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<Style x:Key="BorderCardStyle" TargetType="Border">
<Setter Property="Background" Value="{ThemeResource CardBackgroundFillColorDefaultBrush}"/>

View File

@@ -8,6 +8,9 @@
<ItemsPanelTemplate x:Key="WrapPanelSpacing0Template">
<cwcont:WrapPanel/>
</ItemsPanelTemplate>
<ItemsPanelTemplate x:Key="WrapPanelSpacing2Template">
<cwcont:WrapPanel HorizontalSpacing="2" VerticalSpacing="2"/>
</ItemsPanelTemplate>
<ItemsPanelTemplate x:Key="WrapPanelSpacing4Template">
<cwcont:WrapPanel HorizontalSpacing="4" VerticalSpacing="4"/>
</ItemsPanelTemplate>

View File

@@ -0,0 +1,25 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Win32;
using Windows.UI;
namespace Snap.Hutao.Control.Theme;
internal static class SystemColors
{
public static Color BaseLowColor(bool isDarkMode)
{
return isDarkMode ? StructMarshal.Color(0x33FFFFFF) : StructMarshal.Color(0x33000000);
}
public static Color BaseMediumLowColor(bool isDarkMode)
{
return isDarkMode ? StructMarshal.Color(0x66FFFFFF) : StructMarshal.Color(0x66000000);
}
public static Color BaseHighColor(bool isDarkMode)
{
return isDarkMode ? StructMarshal.Color(0xFFFFFFFF) : StructMarshal.Color(0xFF000000);
}
}

View File

@@ -32,6 +32,14 @@ internal sealed class HutaoException : Exception
}
}
public static void ThrowIfNot(bool condition, HutaoExceptionKind kind, string message, Exception? innerException = default)
{
if (!condition)
{
throw new HutaoException(kind, message, innerException);
}
}
public static HutaoException ServiceTypeCastFailed<TFrom, TTo>(string name, Exception? innerException = default)
{
string message = $"This instance of '{typeof(TFrom).FullName}' '{name}' doesn't implement '{typeof(TTo).FullName}'";

View File

@@ -6,8 +6,15 @@ namespace Snap.Hutao.Core.ExceptionService;
internal enum HutaoExceptionKind
{
None,
// Foundation
ServiceTypeCastFailed,
// IO
FileSystemCreateFileInsufficientPermissions,
PrivateNamedPipeContentHashIncorrect,
// Service
GachaStatisticsInvalidItemId,
GameFpsUnlockingFailed,
}

View File

@@ -9,6 +9,7 @@ namespace Snap.Hutao.Core.Validation;
/// 封装验证方法,简化微软验证
/// </summary>
[HighQuality]
[Obsolete("Use HutaoException instead")]
internal static class Must
{
/// <summary>

View File

@@ -13,6 +13,7 @@ using Snap.Hutao.Win32;
using Snap.Hutao.Win32.Foundation;
using Snap.Hutao.Win32.Graphics.Dwm;
using Snap.Hutao.Win32.UI.WindowsAndMessaging;
using System.Collections.Frozen;
using System.IO;
using Windows.Graphics;
using Windows.UI;
@@ -56,25 +57,22 @@ internal sealed class WindowController
private void InitializeCore()
{
RuntimeOptions runtimeOptions = serviceProvider.GetRequiredService<RuntimeOptions>();
AppOptions appOptions = serviceProvider.GetRequiredService<AppOptions>();
window.AppWindow.Title = SH.FormatAppNameAndVersion(runtimeOptions.Version);
window.AppWindow.SetIcon(Path.Combine(runtimeOptions.InstalledLocation, "Assets/Logo.ico"));
ExtendsContentIntoTitleBar();
RecoverOrInitWindowSize();
UpdateElementTheme(appOptions.ElementTheme);
UpdateImmersiveDarkMode(options.TitleBar, default!);
// appWindow.Show(true);
// appWindow.Show can't bring window to top.
window.Activate();
options.BringToForeground();
if (options.UseSystemBackdrop)
{
AppOptions appOptions = serviceProvider.GetRequiredService<AppOptions>();
UpdateSystemBackdrop(appOptions.BackdropType);
appOptions.PropertyChanged += OnOptionsPropertyChanged;
}
subclass.Initialize();
@@ -190,12 +188,12 @@ internal sealed class WindowController
appTitleBar.ButtonBackgroundColor = Colors.Transparent;
appTitleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
IAppResourceProvider resourceProvider = serviceProvider.GetRequiredService<IAppResourceProvider>();
bool isDarkMode = Control.Theme.ThemeHelper.IsDarkMode(options.TitleBar.ActualTheme);
Color systemBaseLowColor = resourceProvider.GetResource<Color>("SystemBaseLowColor");
Color systemBaseLowColor = Control.Theme.SystemColors.BaseLowColor(isDarkMode);
appTitleBar.ButtonHoverBackgroundColor = systemBaseLowColor;
Color systemBaseMediumLowColor = resourceProvider.GetResource<Color>("SystemBaseMediumLowColor");
Color systemBaseMediumLowColor = Control.Theme.SystemColors.BaseMediumLowColor(isDarkMode);
appTitleBar.ButtonPressedBackgroundColor = systemBaseMediumLowColor;
// The Foreground doesn't accept Alpha channel. So we translate it to gray.
@@ -203,7 +201,7 @@ internal sealed class WindowController
byte result = (byte)((systemBaseMediumLowColor.A / 255.0) * light);
appTitleBar.ButtonInactiveForegroundColor = Color.FromArgb(0xFF, result, result, result);
Color systemBaseHighColor = resourceProvider.GetResource<Color>("SystemBaseHighColor");
Color systemBaseHighColor = Control.Theme.SystemColors.BaseHighColor(isDarkMode);
appTitleBar.ButtonForegroundColor = systemBaseHighColor;
appTitleBar.ButtonHoverForegroundColor = systemBaseHighColor;
appTitleBar.ButtonPressedForegroundColor = systemBaseHighColor;

View File

@@ -41,21 +41,18 @@ internal readonly struct WindowOptions
/// </summary>
public readonly bool PersistSize;
public readonly bool UseSystemBackdrop;
/// <summary>
/// 是否使用 Win UI 3 自带的拓展标题栏实现
/// </summary>
public readonly bool UseLegacyDragBarImplementation = !AppWindowTitleBar.IsCustomizationSupported();
public WindowOptions(Window window, FrameworkElement titleBar, SizeInt32 initSize, bool persistSize = false, bool useSystemBackdrop = true)
public WindowOptions(Window window, FrameworkElement titleBar, SizeInt32 initSize, bool persistSize = false)
{
Hwnd = WindowNative.GetWindowHandle(window);
InputNonClientPointerSource = InputNonClientPointerSource.GetForWindowId(window.AppWindow.Id);
TitleBar = titleBar;
InitSize = initSize;
PersistSize = persistSize;
UseSystemBackdrop = useSystemBackdrop;
}
/// <summary>

View File

@@ -2381,6 +2381,9 @@
<data name="ViewPageSettingBackgroundImageHeader" xml:space="preserve">
<value>背景图片</value>
</data>
<data name="ViewPageSettingBackgroundImageLocalFolderCopyrightHeader" xml:space="preserve">
<value>当前背景为本地图片,如遇版权问题由用户个人负责</value>
</data>
<data name="ViewPageSettingCacheFolderDescription" xml:space="preserve">
<value>图片缓存 在此处存放</value>
</data>

View File

@@ -18,9 +18,9 @@ internal sealed class GachaTypeComparer : IComparer<GachaType>
KeyValuePair.Create(GachaType.ActivityAvatar, 0),
KeyValuePair.Create(GachaType.SpecialActivityAvatar, 1),
KeyValuePair.Create(GachaType.ActivityWeapon, 2),
KeyValuePair.Create(GachaType.Standard, 3),
KeyValuePair.Create(GachaType.NewBie, 4),
KeyValuePair.Create(GachaType.ActivityCity, 5),
KeyValuePair.Create(GachaType.ActivityCity, 3),
KeyValuePair.Create(GachaType.Standard, 4),
KeyValuePair.Create(GachaType.NewBie, 5),
]);
/// <summary>

View File

@@ -38,6 +38,10 @@ internal sealed class PullPrediction
typedWishSummary.PredictedPullLeftToOrange = result.PredictedPullLeftToOrange;
typedWishSummary.IsPredictPullAvailable = true;
}
else
{
await barrier.SignalAndWaitAsync().ConfigureAwait(false);
}
}
private static PredictResult PredictCore(List<PullCount> distribution, TypedWishSummary typedWishSummary)

View File

@@ -109,7 +109,7 @@ internal sealed class LaunchOptions : DbStoreOptions
public bool IsEnabled
{
get => GetOption(ref isEnabled, SettingEntry.LaunchIsLaunchOptionsEnabled, true);
get => GetOption(ref isEnabled, SettingEntry.LaunchIsLaunchOptionsEnabled, false);
set => SetOption(ref isEnabled, SettingEntry.LaunchIsLaunchOptionsEnabled, value);
}

View File

@@ -17,9 +17,9 @@ internal sealed class LaunchStatus
public string Description { get; set; }
public static LaunchStatus FromUnlockStatus(UnlockerStatus unlockerStatus)
public static LaunchStatus FromUnlockState(GameFpsUnlockerState unlockerState)
{
if (unlockerStatus.FindModuleState == FindModuleResult.Ok)
if (unlockerState.FindModuleResult == FindModuleResult.Ok)
{
return new(LaunchPhase.UnlockFpsSucceed, SH.ServiceGameLaunchPhaseUnlockFpsSucceed);
}

View File

@@ -30,6 +30,7 @@ internal sealed class LaunchExecutionBetterGenshinImpactAutomationHandlder : ILa
}
catch (InvalidOperationException)
{
context.Logger.LogInformation("Failed to wait Input idle waiting");
return;
}

View File

@@ -1,18 +1,27 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using System.Diagnostics;
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionEnsureGameNotRunningHandler : ILaunchExecutionDelegateHandler
{
public static bool IsGameRunning([NotNullWhen(true)] out System.Diagnostics.Process? runningProcess)
public static bool IsGameRunning([NotNullWhen(true)] out Process? runningProcess)
{
int currentSessionId = Process.GetCurrentProcess().SessionId;
// GetProcesses once and manually loop is O(n)
foreach (ref readonly System.Diagnostics.Process process in System.Diagnostics.Process.GetProcesses().AsSpan())
foreach (ref readonly Process process in Process.GetProcesses().AsSpan())
{
if (string.Equals(process.ProcessName, GameConstants.YuanShenProcessName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(process.ProcessName, GameConstants.GenshinImpactProcessName, StringComparison.OrdinalIgnoreCase))
{
if (process.SessionId != currentSessionId)
{
continue;
}
runningProcess = process;
return true;
}
@@ -24,7 +33,7 @@ internal sealed class LaunchExecutionEnsureGameNotRunningHandler : ILaunchExecut
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
if (IsGameRunning(out System.Diagnostics.Process? process))
if (IsGameRunning(out Process? process))
{
context.Logger.LogInformation("Game process detected, id: {Id}", process.Id);

View File

@@ -18,12 +18,13 @@ internal sealed class LaunchExecutionUnlockFpsHandler : ILaunchExecutionDelegate
context.Progress.Report(new(LaunchPhase.UnlockingFps, SH.ServiceGameLaunchPhaseUnlockingFps));
IProgressFactory progressFactory = context.ServiceProvider.GetRequiredService<IProgressFactory>();
IProgress<UnlockerStatus> progress = progressFactory.CreateForMainThread<UnlockerStatus>(status => context.Progress.Report(LaunchStatus.FromUnlockStatus(status)));
GameFpsUnlocker unlocker = context.ServiceProvider.CreateInstance<GameFpsUnlocker>(context.Process);
IProgress<GameFpsUnlockerState> progress = progressFactory.CreateForMainThread<GameFpsUnlockerState>(status => context.Progress.Report(LaunchStatus.FromUnlockState(status)));
GameFpsUnlocker unlocker = new(context.ServiceProvider, context.Process, new(100, 20000, 3000), progress);
try
{
await unlocker.UnlockAsync(new(100, 20000, 3000), progress, context.CancellationToken).ConfigureAwait(false);
await unlocker.UnlockAsync(context.CancellationToken).ConfigureAwait(false);
unlocker.PostUnlockAsync(context.CancellationToken).SafeForget();
}
catch (InvalidOperationException ex)
{

View File

@@ -24,25 +24,25 @@ internal sealed class LaunchExecutionInvoker
handlers.Enqueue(new LaunchExecutionGameProcessInitializationHandler());
handlers.Enqueue(new LaunchExecutionSetDiscordActivityHandler());
handlers.Enqueue(new LaunchExecutionGameProcessStartHandler());
handlers.Enqueue(new LaunchExecutionUnlockFpsHandler());
handlers.Enqueue(new LaunchExecutionStarwardPlayTimeStatisticsHandler());
handlers.Enqueue(new LaunchExecutionBetterGenshinImpactAutomationHandlder());
handlers.Enqueue(new LaunchExecutionUnlockFpsHandler());
handlers.Enqueue(new LaunchExecutionGameProcessExitHandler());
}
public async ValueTask<LaunchExecutionResult> InvokeAsync(LaunchExecutionContext context)
{
await InvokeHandlerAsync(context).ConfigureAwait(false);
await RecursiveInvokeHandlerAsync(context).ConfigureAwait(false);
return context.Result;
}
private async ValueTask<LaunchExecutionContext> InvokeHandlerAsync(LaunchExecutionContext context)
private async ValueTask<LaunchExecutionContext> RecursiveInvokeHandlerAsync(LaunchExecutionContext context)
{
if (handlers.TryDequeue(out ILaunchExecutionDelegateHandler? handler))
{
string typeName = TypeNameHelper.GetTypeDisplayName(handler, false);
context.Logger.LogInformation("Handler [{Handler}] begin execution", typeName);
await handler.OnExecutionAsync(context, () => InvokeHandlerAsync(context)).ConfigureAwait(false);
await handler.OnExecutionAsync(context, () => RecursiveInvokeHandlerAsync(context)).ConfigureAwait(false);
context.Logger.LogInformation("Handler [{Handler}] end execution", typeName);
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Win32.Foundation;
using Snap.Hutao.Win32.Memory;
using System.Diagnostics;
using static Snap.Hutao.Win32.Kernel32;
namespace Snap.Hutao.Service.Game.Unlocker;
internal static class GameFpsAddress
{
#pragma warning disable SA1310
private const byte ASM_CALL = 0xE8;
private const byte ASM_JMP = 0xE9;
#pragma warning restore SA1310
public static unsafe void UnsafeFindFpsAddress(GameFpsUnlockerState state, in RequiredGameModule requiredGameModule)
{
bool readOk = UnsafeReadModulesMemory(state.GameProcess, requiredGameModule, out VirtualMemory localMemory);
HutaoException.ThrowIfNot(readOk, HutaoExceptionKind.GameFpsUnlockingFailed, SH.ServiceGameUnlockerReadModuleMemoryCopyVirtualMemoryFailed);
using (localMemory)
{
int offset = IndexOfPattern(localMemory.AsSpan()[(int)requiredGameModule.UnityPlayer.Size..]);
HutaoException.ThrowIfNot(offset >= 0, HutaoExceptionKind.GameFpsUnlockingFailed, SH.ServiceGameUnlockerInterestedPatternNotFound);
byte* pLocalMemory = (byte*)localMemory.Pointer;
ref readonly Module unityPlayer = ref requiredGameModule.UnityPlayer;
ref readonly Module userAssembly = ref requiredGameModule.UserAssembly;
nuint localMemoryUnityPlayerAddress = (nuint)pLocalMemory;
nuint localMemoryUserAssemblyAddress = localMemoryUnityPlayerAddress + unityPlayer.Size;
nuint rip = localMemoryUserAssemblyAddress + (uint)offset;
rip += 5U;
rip += (nuint)(*(int*)(rip + 2U) + 6);
nuint address = userAssembly.Address + (rip - localMemoryUserAssemblyAddress);
nuint ptr = 0;
SpinWait.SpinUntil(() => UnsafeReadProcessMemory(state.GameProcess, address, out ptr) && ptr != 0);
rip = ptr - unityPlayer.Address + localMemoryUnityPlayerAddress;
while (*(byte*)rip is ASM_CALL or ASM_JMP)
{
rip += (nuint)(*(int*)(rip + 1) + 5);
}
nuint localMemoryActualAddress = rip + *(uint*)(rip + 2) + 6;
nuint actualOffset = localMemoryActualAddress - localMemoryUnityPlayerAddress;
state.FpsAddress = unityPlayer.Address + actualOffset;
}
}
private static int IndexOfPattern(in ReadOnlySpan<byte> memory)
{
// B9 3C 00 00 00 FF 15
ReadOnlySpan<byte> part = [0xB9, 0x3C, 0x00, 0x00, 0x00, 0xFF, 0x15];
return memory.IndexOf(part);
}
private static unsafe bool UnsafeReadModulesMemory(Process process, in RequiredGameModule moduleEntryInfo, out VirtualMemory memory)
{
ref readonly Module unityPlayer = ref moduleEntryInfo.UnityPlayer;
ref readonly Module userAssembly = ref moduleEntryInfo.UserAssembly;
memory = new VirtualMemory(unityPlayer.Size + userAssembly.Size);
return ReadProcessMemory(process.Handle, (void*)unityPlayer.Address, memory.AsSpan()[..(int)unityPlayer.Size], out _)
&& ReadProcessMemory(process.Handle, (void*)userAssembly.Address, memory.AsSpan()[(int)unityPlayer.Size..], out _);
}
private static unsafe bool UnsafeReadProcessMemory(Process process, nuint baseAddress, out nuint value)
{
value = 0;
bool result = ReadProcessMemory((HANDLE)process.Handle, (void*)baseAddress, ref value, out _);
HutaoException.ThrowIfNot(result, HutaoExceptionKind.GameFpsUnlockingFailed, SH.ServiceGameUnlockerReadProcessMemoryPointerAddressFailed);
return result;
}
}

View File

@@ -1,11 +1,9 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.Diagnostics;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Win32.Foundation;
using Snap.Hutao.Win32.Memory;
using Snap.Hutao.Win32.System.ProcessStatus;
using System.Runtime.InteropServices;
using System.Diagnostics;
using static Snap.Hutao.Win32.Kernel32;
namespace Snap.Hutao.Service.Game.Unlocker;
@@ -17,255 +15,54 @@ namespace Snap.Hutao.Service.Game.Unlocker;
[HighQuality]
internal sealed class GameFpsUnlocker : IGameFpsUnlocker
{
private readonly System.Diagnostics.Process gameProcess;
private readonly LaunchOptions launchOptions;
private readonly UnlockerStatus status = new();
private readonly GameFpsUnlockerState state = new();
/// <summary>
/// 构造一个新的 <see cref="GameFpsUnlocker"/> 对象,
/// 每个解锁器只能解锁一次原神的进程,
/// 再次解锁需要重新创建对象
/// <para/>
/// 解锁器需要在管理员模式下才能正确的完成解锁操作,
/// 非管理员模式不能解锁
/// </summary>
/// <param name="serviceProvider">服务提供器</param>
/// <param name="gameProcess">游戏进程</param>
public GameFpsUnlocker(IServiceProvider serviceProvider, System.Diagnostics.Process gameProcess)
public GameFpsUnlocker(IServiceProvider serviceProvider, Process gameProcess, in UnlockTimingOptions options, IProgress<GameFpsUnlockerState> progress)
{
launchOptions = serviceProvider.GetRequiredService<LaunchOptions>();
this.gameProcess = gameProcess;
state.GameProcess = gameProcess;
state.TimingOptions = options;
state.Progress = progress;
}
/// <inheritdoc/>
public async ValueTask UnlockAsync(UnlockTimingOptions options, IProgress<UnlockerStatus> progress, CancellationToken token = default)
public async ValueTask UnlockAsync(CancellationToken token = default)
{
Verify.Operation(status.IsUnlockerValid, "This Unlocker is invalid");
HutaoException.ThrowIfNot(state.IsUnlockerValid, HutaoExceptionKind.GameFpsUnlockingFailed, "This Unlocker is invalid");
(FindModuleResult result, RequiredGameModule gameModule) = await GameProcessModule.FindModuleAsync(state).ConfigureAwait(false);
HutaoException.ThrowIfNot(result != FindModuleResult.TimeLimitExeeded, HutaoExceptionKind.GameFpsUnlockingFailed, SH.ServiceGameUnlockerFindModuleTimeLimitExeeded);
HutaoException.ThrowIfNot(result != FindModuleResult.NoModuleFound, HutaoExceptionKind.GameFpsUnlockingFailed, SH.ServiceGameUnlockerFindModuleNoModuleFound);
(FindModuleResult result, GameModule moduleEntryInfo) = await FindModuleAsync(options.FindModuleDelay, options.FindModuleLimit).ConfigureAwait(false);
Verify.Operation(result != FindModuleResult.TimeLimitExeeded, SH.ServiceGameUnlockerFindModuleTimeLimitExeeded);
Verify.Operation(result != FindModuleResult.NoModuleFound, SH.ServiceGameUnlockerFindModuleNoModuleFound);
// Read UnityPlayer.dll
UnsafeFindFpsAddress(moduleEntryInfo);
progress.Report(status);
// When player switch between scenes, we have to re adjust the fps
// So we keep a loop here
await LoopAdjustFpsAsync(options.AdjustFpsDelay, progress, token).ConfigureAwait(false);
GameFpsAddress.UnsafeFindFpsAddress(state, gameModule);
state.Report();
}
private static unsafe bool UnsafeReadModulesMemory(System.Diagnostics.Process process, in GameModule moduleEntryInfo, out VirtualMemory memory)
public async ValueTask PostUnlockAsync(CancellationToken token = default)
{
ref readonly Module unityPlayer = ref moduleEntryInfo.UnityPlayer;
ref readonly Module userAssembly = ref moduleEntryInfo.UserAssembly;
memory = new VirtualMemory(unityPlayer.Size + userAssembly.Size);
return ReadProcessMemory(process.Handle, (void*)unityPlayer.Address, memory.AsSpan()[..(int)unityPlayer.Size], out _)
&& ReadProcessMemory(process.Handle, (void*)userAssembly.Address, memory.AsSpan()[(int)unityPlayer.Size..], out _);
}
private static unsafe bool UnsafeReadProcessMemory(System.Diagnostics.Process process, nuint baseAddress, out nuint value)
{
value = 0;
bool result = ReadProcessMemory((HANDLE)process.Handle, (void*)baseAddress, ref value, out _);
Verify.Operation(result, SH.ServiceGameUnlockerReadProcessMemoryPointerAddressFailed);
return result;
}
private static unsafe bool UnsafeWriteProcessMemory(System.Diagnostics.Process process, nuint baseAddress, int value)
{
return WriteProcessMemory((HANDLE)process.Handle, (void*)baseAddress, ref value, out _);
}
private static unsafe FindModuleResult UnsafeTryFindModule(in HANDLE hProcess, in ReadOnlySpan<char> moduleName, out Module module)
{
HMODULE[] buffer = new HMODULE[128];
if (!K32EnumProcessModules(hProcess, buffer, out uint actualSize))
{
Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError());
}
if (actualSize == 0)
{
module = default!;
return FindModuleResult.NoModuleFound;
}
foreach (ref readonly HMODULE hModule in buffer.AsSpan()[..(int)(actualSize / sizeof(HMODULE))])
{
char[] baseName = new char[256];
if (K32GetModuleBaseNameW(hProcess, hModule, baseName) == 0)
{
continue;
}
fixed (char* lpBaseName = baseName)
{
ReadOnlySpan<char> szModuleName = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(lpBaseName);
if (!szModuleName.SequenceEqual(moduleName))
{
continue;
}
}
if (!K32GetModuleInformation(hProcess, hModule, out MODULEINFO moduleInfo))
{
continue;
}
module = new((nuint)moduleInfo.lpBaseOfDll, moduleInfo.SizeOfImage);
return FindModuleResult.Ok;
}
module = default;
return FindModuleResult.ModuleNotLoaded;
}
private static int IndexOfPattern(in ReadOnlySpan<byte> memory)
{
// B9 3C 00 00 00 FF 15
ReadOnlySpan<byte> part = [0xB9, 0x3C, 0x00, 0x00, 0x00, 0xFF, 0x15];
return memory.IndexOf(part);
}
private static FindModuleResult UnsafeGetGameModuleInfo(in HANDLE hProcess, out GameModule info)
{
FindModuleResult unityPlayerResult = UnsafeTryFindModule(hProcess, "UnityPlayer.dll", out Module unityPlayer);
FindModuleResult userAssemblyResult = UnsafeTryFindModule(hProcess, "UserAssembly.dll", out Module userAssembly);
if (unityPlayerResult == FindModuleResult.Ok && userAssemblyResult == FindModuleResult.Ok)
{
info = new(unityPlayer, userAssembly);
return FindModuleResult.Ok;
}
if (unityPlayerResult == FindModuleResult.NoModuleFound && userAssemblyResult == FindModuleResult.NoModuleFound)
{
info = default;
return FindModuleResult.NoModuleFound;
}
info = default;
return FindModuleResult.ModuleNotLoaded;
}
private async ValueTask<ValueResult<FindModuleResult, GameModule>> FindModuleAsync(TimeSpan findModuleDelay, TimeSpan findModuleLimit)
{
ValueStopwatch watch = ValueStopwatch.StartNew();
using (PeriodicTimer timer = new(findModuleDelay))
{
while (await timer.WaitForNextTickAsync().ConfigureAwait(false))
{
FindModuleResult result = UnsafeGetGameModuleInfo((HANDLE)gameProcess.Handle, out GameModule gameModule);
if (result == FindModuleResult.Ok)
{
return new(FindModuleResult.Ok, gameModule);
}
if (result == FindModuleResult.NoModuleFound)
{
return new(FindModuleResult.NoModuleFound, default);
}
if (watch.GetElapsedTime() > findModuleLimit)
{
break;
}
}
}
return new(FindModuleResult.TimeLimitExeeded, default);
}
private async ValueTask LoopAdjustFpsAsync(TimeSpan adjustFpsDelay, IProgress<UnlockerStatus> progress, CancellationToken token)
{
using (PeriodicTimer timer = new(adjustFpsDelay))
using (PeriodicTimer timer = new(state.TimingOptions.AdjustFpsDelay))
{
while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false))
{
if (!gameProcess.HasExited && status.FpsAddress != 0U)
if (!state.GameProcess.HasExited && state.FpsAddress != 0U)
{
UnsafeWriteProcessMemory(gameProcess, status.FpsAddress, launchOptions.TargetFps);
progress.Report(status);
UnsafeWriteProcessMemory(state.GameProcess, state.FpsAddress, launchOptions.TargetFps);
state.Report();
}
else
{
status.IsUnlockerValid = false;
status.FpsAddress = 0;
progress.Report(status);
state.IsUnlockerValid = false;
state.FpsAddress = 0;
state.Report();
return;
}
}
}
}
private unsafe void UnsafeFindFpsAddress(in GameModule moduleEntryInfo)
private static unsafe bool UnsafeWriteProcessMemory(Process process, nuint baseAddress, int value)
{
bool readOk = UnsafeReadModulesMemory(gameProcess, moduleEntryInfo, out VirtualMemory localMemory);
Verify.Operation(readOk, SH.ServiceGameUnlockerReadModuleMemoryCopyVirtualMemoryFailed);
using (localMemory)
{
int offset = IndexOfPattern(localMemory.AsSpan()[(int)moduleEntryInfo.UnityPlayer.Size..]);
Must.Range(offset >= 0, SH.ServiceGameUnlockerInterestedPatternNotFound);
byte* pLocalMemory = (byte*)localMemory.Pointer;
ref readonly Module unityPlayer = ref moduleEntryInfo.UnityPlayer;
ref readonly Module userAssembly = ref moduleEntryInfo.UserAssembly;
nuint localMemoryUnityPlayerAddress = (nuint)pLocalMemory;
nuint localMemoryUserAssemblyAddress = localMemoryUnityPlayerAddress + unityPlayer.Size;
nuint rip = localMemoryUserAssemblyAddress + (uint)offset;
rip += 5U;
rip += (nuint)(*(int*)(rip + 2U) + 6);
nuint address = userAssembly.Address + (rip - localMemoryUserAssemblyAddress);
nuint ptr = 0;
SpinWait.SpinUntil(() => UnsafeReadProcessMemory(gameProcess, address, out ptr) && ptr != 0);
rip = ptr - unityPlayer.Address + localMemoryUnityPlayerAddress;
// CALL or JMP
while (*(byte*)rip == 0xE8 || *(byte*)rip == 0xE9)
{
rip += (nuint)(*(int*)(rip + 1) + 5);
}
nuint localMemoryActualAddress = rip + *(uint*)(rip + 2) + 6;
nuint actualOffset = localMemoryActualAddress - localMemoryUnityPlayerAddress;
status.FpsAddress = unityPlayer.Address + actualOffset;
}
}
private readonly struct GameModule
{
public readonly bool HasValue = false;
public readonly Module UnityPlayer;
public readonly Module UserAssembly;
public GameModule(in Module unityPlayer, in Module userAssembly)
{
HasValue = true;
UnityPlayer = unityPlayer;
UserAssembly = userAssembly;
}
}
private readonly struct Module
{
public readonly bool HasValue = false;
public readonly nuint Address;
public readonly uint Size;
public Module(nuint address, uint size)
{
HasValue = true;
Address = address;
Size = size;
}
return WriteProcessMemory((HANDLE)process.Handle, (void*)baseAddress, ref value, out _);
}
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using System.Diagnostics;
namespace Snap.Hutao.Service.Game.Unlocker;
/// <summary>
/// 解锁状态
/// </summary>
internal sealed class GameFpsUnlockerState
{
public string Description { get; set; } = default!;
public FindModuleResult FindModuleResult { get; set; }
public bool IsUnlockerValid { get; set; } = true;
public nuint FpsAddress { get; set; }
public UnlockTimingOptions TimingOptions { get; set; }
public Process GameProcess { get; set; } = default!;
public IProgress<GameFpsUnlockerState> Progress { get; set; } = default!;
public void Report()
{
Progress.Report(this);
}
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.Diagnostics;
using Snap.Hutao.Win32.Foundation;
using Snap.Hutao.Win32.Memory;
using Snap.Hutao.Win32.System.ProcessStatus;
using System.Diagnostics;
using System.Runtime.InteropServices;
using static Snap.Hutao.Win32.Kernel32;
namespace Snap.Hutao.Service.Game.Unlocker;
internal static class GameProcessModule
{
public static async ValueTask<ValueResult<FindModuleResult, RequiredGameModule>> FindModuleAsync(GameFpsUnlockerState state)
{
ValueStopwatch watch = ValueStopwatch.StartNew();
using (PeriodicTimer timer = new(state.TimingOptions.FindModuleDelay))
{
while (await timer.WaitForNextTickAsync().ConfigureAwait(false))
{
FindModuleResult result = UnsafeGetGameModuleInfo((HANDLE)state.GameProcess.Handle, out RequiredGameModule gameModule);
if (result == FindModuleResult.Ok)
{
return new(FindModuleResult.Ok, gameModule);
}
if (result == FindModuleResult.NoModuleFound)
{
return new(FindModuleResult.NoModuleFound, default);
}
if (watch.GetElapsedTime() > state.TimingOptions.FindModuleLimit)
{
break;
}
}
}
return new(FindModuleResult.TimeLimitExeeded, default);
}
private static FindModuleResult UnsafeGetGameModuleInfo(in HANDLE hProcess, out RequiredGameModule info)
{
FindModuleResult unityPlayerResult = UnsafeFindModule(hProcess, "UnityPlayer.dll", out Module unityPlayer);
FindModuleResult userAssemblyResult = UnsafeFindModule(hProcess, "UserAssembly.dll", out Module userAssembly);
if (unityPlayerResult is FindModuleResult.Ok && userAssemblyResult is FindModuleResult.Ok)
{
info = new(unityPlayer, userAssembly);
return FindModuleResult.Ok;
}
if (unityPlayerResult is FindModuleResult.NoModuleFound && userAssemblyResult is FindModuleResult.NoModuleFound)
{
info = default;
return FindModuleResult.NoModuleFound;
}
info = default;
return FindModuleResult.ModuleNotLoaded;
}
private static unsafe FindModuleResult UnsafeFindModule(in HANDLE hProcess, in ReadOnlySpan<char> moduleName, out Module module)
{
HMODULE[] buffer = new HMODULE[128];
if (!K32EnumProcessModules(hProcess, buffer, out uint actualSize))
{
Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError());
}
if (actualSize == 0)
{
module = default!;
return FindModuleResult.NoModuleFound;
}
foreach (ref readonly HMODULE hModule in buffer.AsSpan()[..(int)(actualSize / sizeof(HMODULE))])
{
char[] baseName = new char[256];
if (K32GetModuleBaseNameW(hProcess, hModule, baseName) == 0)
{
continue;
}
fixed (char* lpBaseName = baseName)
{
if (!moduleName.SequenceEqual(MemoryMarshal.CreateReadOnlySpanFromNullTerminated(lpBaseName)))
{
continue;
}
}
if (!K32GetModuleInformation(hProcess, hModule, out MODULEINFO moduleInfo))
{
continue;
}
module = new((nuint)moduleInfo.lpBaseOfDll, moduleInfo.SizeOfImage);
return FindModuleResult.Ok;
}
module = default;
return FindModuleResult.ModuleNotLoaded;
}
}

View File

@@ -9,12 +9,7 @@ namespace Snap.Hutao.Service.Game.Unlocker;
[HighQuality]
internal interface IGameFpsUnlocker
{
/// <summary>
/// 异步的解锁帧数限制
/// </summary>
/// <param name="options">选项</param>
/// <param name="progress">进度</param>
/// <param name="token">取消令牌</param>
/// <returns>解锁的结果</returns>
ValueTask UnlockAsync(UnlockTimingOptions options, IProgress<UnlockerStatus> progress, CancellationToken token = default);
ValueTask PostUnlockAsync(CancellationToken token = default);
ValueTask UnlockAsync(CancellationToken token = default);
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game.Unlocker;
internal readonly struct Module
{
public readonly bool HasValue = false;
public readonly nuint Address;
public readonly uint Size;
public Module(nuint address, uint size)
{
HasValue = true;
Address = address;
Size = size;
}
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game.Unlocker;
internal readonly struct RequiredGameModule
{
public readonly bool HasValue = false;
public readonly Module UnityPlayer;
public readonly Module UserAssembly;
public RequiredGameModule(in Module unityPlayer, in Module userAssembly)
{
HasValue = true;
UnityPlayer = unityPlayer;
UserAssembly = userAssembly;
}
}

View File

@@ -1,30 +0,0 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game.Unlocker;
/// <summary>
/// 解锁状态
/// </summary>
internal sealed class UnlockerStatus
{
/// <summary>
/// 状态描述
/// </summary>
public string Description { get; set; } = default!;
/// <summary>
/// 查找模块状态
/// </summary>
public FindModuleResult FindModuleState { get; set; }
/// <summary>
/// 当前解锁器是否有效
/// </summary>
public bool IsUnlockerValid { get; set; } = true;
/// <summary>
/// FPS 字节地址
/// </summary>
public nuint FpsAddress { get; set; }
}

View File

@@ -15,6 +15,7 @@
HorizontalContentAlignment="Stretch"
d:DataContext="{d:DesignInstance shva:AchievementViewModelSlim}"
Background="Transparent"
BorderBrush="{x:Null}"
Command="{Binding NavigateCommand}"
Style="{ThemeResource DefaultButtonStyle}"
mc:Ignorable="d">

View File

@@ -17,6 +17,7 @@
HorizontalContentAlignment="Stretch"
d:DataContext="{d:DesignInstance shvd:DailyNoteViewModelSlim}"
Background="Transparent"
BorderBrush="{x:Null}"
Command="{Binding NavigateCommand}"
Style="{ThemeResource DefaultButtonStyle}"
mc:Ignorable="d">

View File

@@ -16,6 +16,7 @@
HorizontalContentAlignment="Stretch"
d:DataContext="{d:DesignInstance shvg:GachaLogViewModelSlim}"
Background="Transparent"
BorderBrush="{x:Null}"
Command="{Binding NavigateCommand}"
Style="{ThemeResource DefaultButtonStyle}"
mc:Ignorable="d">

View File

@@ -17,6 +17,7 @@
VerticalContentAlignment="Stretch"
d:DataContext="{d:DesignInstance shvg:LaunchGameViewModelSlim}"
Background="Transparent"
BorderBrush="{x:Null}"
Command="{Binding LaunchCommand}"
Style="{ThemeResource DefaultButtonStyle}"
mc:Ignorable="d">

View File

@@ -21,6 +21,12 @@
mc:Ignorable="d">
<UserControl.Resources>
<shvconv:BoolToGridLengthConverter x:Key="BoolToGridLengthConverter"/>
<shvconv:BoolToGridLengthConverter
x:Key="BoolToGridLengthSpacingConverter"
FalseValue="0"
TrueValue="4"/>
<shvconv:Int32ToGradientColorConverter x:Key="Int32ToGradientColorConverter" MaximumValue="{Binding GuaranteeOrangeThreshold}"/>
<DataTemplate x:Key="OrangeListTemplate" d:DataType="shvg:SummaryItem">
@@ -226,10 +232,12 @@
Padding="0,12"
Value="{x:Bind StatisticsSegmented.SelectedIndex, Mode=OneWay}">
<cwcont:Case Value="{shcm:Int32 Value=0}">
<Grid ColumnSpacing="2">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition Width="{x:Bind ShowUpPull, Converter={StaticResource BoolToGridLengthSpacingConverter}}"/>
<ColumnDefinition Width="{x:Bind ShowUpPull, Converter={StaticResource BoolToGridLengthConverter}}"/>
<ColumnDefinition Width="4"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Style="{ThemeResource BorderCardStyle}">
@@ -243,7 +251,7 @@
</StackPanel>
</Viewbox>
</Border>
<Border Grid.Column="1" Style="{ThemeResource BorderCardStyle}">
<Border Grid.Column="2" Style="{ThemeResource BorderCardStyle}">
<Viewbox Margin="8,0" StretchDirection="DownOnly">
<Grid>
<StackPanel
@@ -266,7 +274,7 @@
</Viewbox>
</Border>
<Grid Grid.Column="2" RowSpacing="2">
<Grid Grid.Column="4" RowSpacing="4">
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
@@ -298,7 +306,7 @@
<RowDefinition/>
<RowDefinition Height="auto"/>
</Grid.RowDefinitions>
<Grid ColumnSpacing="2">
<Grid ColumnSpacing="4">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
@@ -360,7 +368,7 @@
</cwcont:Case>
<cwcont:Case Value="{shcm:Int32 Value=2}">
<Grid RowSpacing="2">
<Grid RowSpacing="4">
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>

View File

@@ -0,0 +1,16 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using CommunityToolkit.WinUI.Converters;
using Microsoft.UI.Xaml;
namespace Snap.Hutao.View.Converter;
internal sealed class BoolToGridLengthConverter : BoolToObjectConverter
{
public BoolToGridLengthConverter()
{
TrueValue = new GridLength(1D, GridUnitType.Star);
FalseValue = new GridLength(0D);
}
}

View File

@@ -212,7 +212,7 @@
LocalSettingKeySuffixForCurrent="AchievementPage.AchievementGoals"/>
<Viewbox
Height="32"
MaxWidth="192"
MaxWidth="190"
Margin="8,0,0,0"
HorizontalAlignment="Left"
Stretch="Uniform"

View File

@@ -43,6 +43,7 @@
cw:VisualExtensions.NormalizedCenterPoint="0.5">
<cww:ConstrainedBox AspectRatio="1080:390" CornerRadius="{ThemeResource ControlCornerRadiusTop}">
<shci:CachedImage
Margin="-1"
VerticalAlignment="Center"
PlaceholderMargin="16"
PlaceholderSource="{StaticResource UI_EmotionIcon271}"

View File

@@ -3,6 +3,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:clw="using:CommunityToolkit.Labs.WinUI"
xmlns:cw="using:CommunityToolkit.WinUI"
xmlns:cwcont="using:CommunityToolkit.WinUI.Controls"
xmlns:cwconv="using:CommunityToolkit.WinUI.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
@@ -50,6 +51,7 @@
<SplitView.Pane>
<ScrollViewer>
<StackPanel Margin="16" Spacing="3">
<Border cw:Effects.Shadow="{ThemeResource CompatCardShadow}">
<Border Style="{ThemeResource AcrylicBorderCardStyle}">
<cwcont:SettingsExpander
Description="{Binding RuntimeOptions.Version}"
@@ -79,6 +81,8 @@
</cwcont:SettingsExpander.Items>
</cwcont:SettingsExpander>
</Border>
</Border>
<Border cw:Effects.Shadow="{ThemeResource CompatCardShadow}">
<Border Style="{ThemeResource AcrylicBorderCardStyle}">
<cwcont:SettingsExpander
Description="{shcm:ResourceString Name=ViewPageFeedbackEngageWithUsDescription}"
@@ -107,6 +111,8 @@
</cwcont:SettingsExpander.Items>
</cwcont:SettingsExpander>
</Border>
</Border>
<Border cw:Effects.Shadow="{ThemeResource CompatCardShadow}">
<Border Style="{ThemeResource AcrylicBorderCardStyle}">
<cwcont:SettingsExpander
Header="{shcm:ResourceString Name=ViewPageFeedbackFeatureGuideHeader}"
@@ -208,10 +214,12 @@
</cwcont:SettingsExpander.Items>
</cwcont:SettingsExpander>
</Border>
</Border>
</StackPanel>
</ScrollViewer>
</SplitView.Pane>
<Grid Margin="16,16,0,16" Style="{ThemeResource AcrylicGridCardStyle}">
<Border Margin="16,16,0,16" cw:Effects.Shadow="{ThemeResource CompatCardShadow}">
<Grid Style="{ThemeResource AcrylicGridCardStyle}">
<Grid
Padding="16"
RowSpacing="8"
@@ -284,6 +292,7 @@
<clw:Shimmer IsActive="{Binding IsInitialized, Converter={StaticResource BoolNegationConverter}, Mode=OneWay}" Visibility="{Binding IsInitialized, Converter={StaticResource BoolToVisibilityRevertConverter}, Mode=OneWay}"/>
</Grid>
</Border>
</SplitView>
</Grid>
</shc:ScopedPage>

View File

@@ -12,6 +12,7 @@
xmlns:shcb="using:Snap.Hutao.Control.Behavior"
xmlns:shci="using:Snap.Hutao.Control.Image"
xmlns:shcm="using:Snap.Hutao.Control.Markup"
xmlns:shcp="using:Snap.Hutao.Control.Panel"
xmlns:shvc="using:Snap.Hutao.View.Control"
xmlns:shvg="using:Snap.Hutao.ViewModel.GachaLog"
d:DataContext="{d:DesignInstance shvg:GachaLogViewModel}"
@@ -142,8 +143,8 @@
<Border Width="40" Style="{StaticResource BorderCardStyle}">
<StackPanel>
<shvc:ItemIcon
Width="40"
Height="40"
Width="38"
Height="38"
Icon="{Binding Icon}"
Quality="{Binding Quality}"/>
<TextBlock
@@ -182,17 +183,19 @@
Text="{Binding TotalCountFormatted}"/>
<ItemsControl
Grid.Row="1"
MaxWidth="124"
Margin="0,6,0,0"
HorizontalAlignment="Left"
ItemTemplate="{StaticResource HistoryWishItemTemplate}"
ItemsPanel="{StaticResource HorizontalStackPanelSpacing2Template}"
ItemsPanel="{StaticResource WrapPanelSpacing2Template}"
ItemsSource="{Binding OrangeUpList}"/>
<ItemsControl
Grid.Row="1"
MaxWidth="252"
Margin="0,6,0,0"
HorizontalAlignment="Right"
ItemTemplate="{StaticResource HistoryWishItemTemplate}"
ItemsPanel="{StaticResource HorizontalStackPanelSpacing2Template}"
ItemsPanel="{StaticResource WrapPanelSpacing2Template}"
ItemsSource="{Binding PurpleUpList}"/>
<TextBlock
Grid.Row="2"
@@ -285,26 +288,21 @@
</CommandBar>
</Pivot.RightHeader>
<PivotItem Header="{shcm:ResourceString Name=ViewPageGahcaLogPivotOverview}">
<Grid Margin="0,0,16,0">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<shvc:StatisticsCard
Grid.Column="0"
Margin="16,16,0,16"
DataContext="{Binding Statistics.AvatarWish}"/>
<shvc:StatisticsCard
Grid.Column="1"
Margin="16,16,0,16"
DataContext="{Binding Statistics.WeaponWish}"/>
<shvc:StatisticsCard
Grid.Column="2"
Margin="16,16,0,16"
DataContext="{Binding Statistics.StandardWish}"
ShowUpPull="False"/>
</Grid>
<ScrollViewer
Margin="16,0"
HorizontalScrollBarVisibility="Auto"
HorizontalScrollMode="Enabled"
VerticalScrollMode="Disabled">
<shcp:HorizontalEqualPanel
Margin="0,16"
MinItemWidth="302"
Spacing="16">
<shvc:StatisticsCard DataContext="{Binding Statistics.AvatarWish}"/>
<shvc:StatisticsCard DataContext="{Binding Statistics.WeaponWish}"/>
<shvc:StatisticsCard DataContext="{Binding Statistics.StandardWish}" ShowUpPull="False"/>
<shvc:StatisticsCard DataContext="{Binding Statistics.ChronicledWish}" ShowUpPull="False"/>
</shcp:HorizontalEqualPanel>
</ScrollViewer>
</PivotItem>
<PivotItem Header="{shcm:ResourceString Name=ViewPageGahcaLogPivotHistory}">
<Border Margin="16" cw:Effects.Shadow="{ThemeResource CompatCardShadow}">
@@ -312,7 +310,7 @@
<SplitView
DisplayMode="Inline"
IsPaneOpen="True"
OpenPaneLength="323"
OpenPaneLength="408"
PaneBackground="{ThemeResource CardBackgroundFillColorSecondaryBrush}">
<SplitView.Pane>
<ListView
@@ -379,7 +377,6 @@
</SplitView>
</Border>
</Border>
</PivotItem>
<PivotItem Header="{shcm:ResourceString Name=ViewPageGahcaLogPivotAvatar}">
<ScrollViewer>
@@ -487,7 +484,6 @@
<shvc:HutaoStatisticsCard Grid.Column="2" DataContext="{Binding HutaoCloudStatisticsViewModel.Statistics.WeaponEvent}"/>
</Grid>
</Grid>
</PivotItem>
</Pivot>
</Grid>

View File

@@ -348,6 +348,7 @@
</mxic:EventTriggerBehavior>
</mxi:Interaction.Behaviors>
</cwc:SettingsCard>
<cwc:SettingsCard Header="{shcm:ResourceString Name=ViewPageSettingBackgroundImageLocalFolderCopyrightHeader}" Visibility="{Binding BackgroundImageOptions.Wallpaper, Converter={StaticResource EmptyObjectToBoolRevertConverter}}"/>
</cwc:SettingsExpander.Items>
</cwc:SettingsExpander>
</StackPanel>

View File

@@ -203,6 +203,8 @@
</Border>
</cwc:Case>
<cwc:Case Value="Grid">
<Border Margin="16,0,16,16" cw:Effects.Shadow="{ThemeResource CompatCardShadow}">
<Border Style="{ThemeResource AcrylicBorderCardStyle}">
<GridView
Padding="16,16,4,4"
HorizontalAlignment="Stretch"
@@ -216,6 +218,8 @@
<shcb:SelectedItemInViewBehavior/>
</mxi:Interaction.Behaviors>
</GridView>
</Border>
</Border>
</cwc:Case>
</cwc:SwitchPresenter>
</Grid>

View File

@@ -104,10 +104,14 @@ internal sealed partial class GachaLogViewModel : Abstraction.ViewModel
ArgumentNullException.ThrowIfNull(gachaLogService.ArchiveCollection);
ObservableCollection<GachaArchive> archives = gachaLogService.ArchiveCollection;
using (await EnterCriticalExecutionAsync().ConfigureAwait(false))
{
await taskContext.SwitchToMainThreadAsync();
Archives = archives;
HutaoCloudViewModel.RetrieveCommand = RetrieveFromCloudCommand;
await SetSelectedArchiveAndUpdateStatisticsAsync(Archives.SelectedOrDefault(), true).ConfigureAwait(false);
}
return true;
}
}

View File

@@ -92,11 +92,10 @@ internal sealed partial class WikiAvatarViewModel : Abstraction.ViewModel
protected override async ValueTask<bool> InitializeUIAsync()
{
if (!await metadataService.InitializeAsync().ConfigureAwait(false))
if (await metadataService.InitializeAsync().ConfigureAwait(false))
{
try
{
return false;
}
levelAvatarCurveMap = await metadataService.GetLevelToAvatarCurveMapAsync().ConfigureAwait(false);
promotes = await metadataService.GetAvatarPromoteListAsync().ConfigureAwait(false);
@@ -109,9 +108,13 @@ internal sealed partial class WikiAvatarViewModel : Abstraction.ViewModel
await CombineComplexDataAsync(list, idMaterialMap).ConfigureAwait(false);
using (await EnterCriticalExecutionAsync().ConfigureAwait(false))
{
await taskContext.SwitchToMainThreadAsync();
Avatars = new(list, true);
Selected = Avatars.View.ElementAtOrDefault(0);
}
FilterTokens = [];
availableTokens = FrozenDictionary.ToFrozenDictionary(
@@ -126,6 +129,13 @@ internal sealed partial class WikiAvatarViewModel : Abstraction.ViewModel
return true;
}
catch (OperationCanceledException)
{
}
}
return false;
}
private async ValueTask CombineComplexDataAsync(List<Avatar> avatars, Dictionary<MaterialId, Material> idMaterialMap)
{

View File

@@ -52,6 +52,8 @@ internal sealed partial class WikiMonsterViewModel : Abstraction.ViewModel
protected override async ValueTask<bool> InitializeUIAsync()
{
if (await metadataService.InitializeAsync().ConfigureAwait(false))
{
try
{
levelMonsterCurveMap = await metadataService.GetLevelToMonsterCurveMapAsync().ConfigureAwait(false);
@@ -63,12 +65,20 @@ internal sealed partial class WikiMonsterViewModel : Abstraction.ViewModel
}
List<Monster> ordered = monsters.SortBy(m => m.RelationshipId.Value);
await taskContext.SwitchToMainThreadAsync();
using (await EnterCriticalExecutionAsync().ConfigureAwait(false))
{
await taskContext.SwitchToMainThreadAsync();
Monsters = new(ordered, true);
Selected = Monsters.View.ElementAtOrDefault(0);
}
return true;
}
catch (OperationCanceledException)
{
}
}
return false;
}

View File

@@ -91,6 +91,8 @@ internal sealed partial class WikiWeaponViewModel : Abstraction.ViewModel
protected override async Task OpenUIAsync()
{
if (await metadataService.InitializeAsync().ConfigureAwait(false))
{
try
{
levelWeaponCurveMap = await metadataService.GetLevelToWeaponCurveMapAsync().ConfigureAwait(false);
promotes = await metadataService.GetWeaponPromoteListAsync().ConfigureAwait(false);
@@ -105,10 +107,14 @@ internal sealed partial class WikiWeaponViewModel : Abstraction.ViewModel
await CombineComplexDataAsync(list, idMaterialMap).ConfigureAwait(false);
using (await EnterCriticalExecutionAsync().ConfigureAwait(false))
{
await taskContext.SwitchToMainThreadAsync();
Weapons = new(list, true);
Selected = Weapons.View.ElementAtOrDefault(0);
}
FilterTokens = [];
availableTokens = FrozenDictionary.ToFrozenDictionary(
@@ -119,6 +125,10 @@ internal sealed partial class WikiWeaponViewModel : Abstraction.ViewModel
.. IntrinsicFrozen.WeaponTypes.Select(w => KeyValuePair.Create(w, new SearchToken(SearchTokenKind.WeaponType, w, iconUri: WeaponTypeIconConverter.WeaponTypeNameToIconUri(w)))),
]);
}
catch (OperationCanceledException)
{
}
}
}
private async ValueTask CombineComplexDataAsync(List<Weapon> weapons, Dictionary<MaterialId, Material> idMaterialMap)

View File

@@ -0,0 +1,21 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using System.Text;
namespace Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher.Resource;
internal static class LatestPackageExtension
{
public static void Patch(this LatestPackage latest)
{
StringBuilder pathBuilder = new();
foreach (PackageSegment segment in latest.Segments)
{
pathBuilder.AppendLine(segment.Path);
}
latest.Path = pathBuilder.ToStringTrimEndReturn();
latest.Name = latest.Segments[0].Path[..^4]; // .00X
}
}

View File

@@ -46,18 +46,16 @@ internal sealed partial class ResourceClient
.TryCatchSendAsync<Response<GameResource>>(httpClient, logger, token)
.ConfigureAwait(false);
// 补全缺失的信息
// 最新版完整包
if (resp is { Data.Game.Latest: LatestPackage latest })
{
StringBuilder pathBuilder = new();
foreach (PackageSegment segment in latest.Segments)
{
pathBuilder.AppendLine(segment.Path);
latest.Patch();
}
latest.Path = pathBuilder.ToStringTrimEndReturn();
string path = latest.Segments[0].Path[..^4]; // .00X
latest.Name = Path.GetFileName(path);
// 预下载完整包
if (resp is { Data.PreDownloadGame.Latest: LatestPackage preDownloadLatest })
{
preDownloadLatest.Patch();
}
return Response.Response.DefaultIfNull(resp);