13 Commits

Author SHA1 Message Date
fanbook-wangdage
e9ed7928d6 Merge branch 'main' of https://github.com/wangdage12/Snap.Hutao 2026-02-15 11:28:29 +08:00
fanbook-wangdage
4d2943d1c9 新增yae注入获取成就功能、修改yae逻辑
修复msix没有打包解锁器的问题
修复注入时米游社账号登录不起作用的问题
2026-02-15 11:28:17 +08:00
wangdage12
74e9427451 Update README with website and version highlights
Added official website link and highlighted version features.
2026-02-09 20:14:52 +08:00
fanbook-wangdage
cb6d728c35 提升版本号、解决CI报错 2026-02-07 13:17:31 +08:00
fanbook-wangdage
f87b80cc9e Merge branch 'main' of https://github.com/wangdage12/Snap.Hutao 2026-02-07 13:08:35 +08:00
fanbook-wangdage
4b313b134e 新增msi安装界面,修复WebView2权限问题,修复切换服务器时会显示等待进程退出的问题,为页面添加缓存来提示频繁切换页面时的性能 2026-02-07 13:07:51 +08:00
wangdage12
0c775a5d3d Revise README for injection features and dependencies
Updated README to clarify injection functionality and added links to required repositories.
2026-01-29 11:54:32 +08:00
wangdage12
00cd5a8c07 Revise README for installation and server status
Updated installation instructions and server status information.
2026-01-27 14:00:53 +08:00
wangdage12
d93ae2bb83 Update README.md 2026-01-20 11:40:34 +08:00
fanbook-wangdage
2f148488f4 修复工具异步加载问题、添加武器和角色id、提示版本号 2026-01-16 12:33:26 +08:00
fanbook-wangdage
df92894307 支持公告中的发行版字段 2026-01-16 11:39:28 +08:00
fanbook-wangdage
5fad9ad855 提升版本号 2026-01-13 16:49:20 +08:00
fanbook-wangdage
1ed2f4f29e 支持注入时传命令行参数 2026-01-13 16:33:17 +08:00
32 changed files with 934 additions and 112 deletions

View File

@@ -4,7 +4,15 @@
**中文**
胡桃工具箱是一款以 MIT 协议开源的原神工具箱,专为现代化 Windows 平台设计,旨在改善桌面端玩家的游戏体验。
该版本注入功能暂不可用,并且由于缺失资源和开发能力,不建议长期使用
自带的注入功能只有FPS调整只保证FPS调整长期可用你可以使用`注入选项`下方的第三方工具来使用更多功能,本项目提供的所有注入功能都不会影响游戏的公平性。
官网https://htserver.wdg.cloudns.ch/
**该版本的特点:**
- 尽量保留原版功能,少重写功能,稳定性强
- 只集成没有争议的安全的注入功能
- 大部分注入功能以第三方工具形式提供,点击即用
- 永久免费的云抽卡日志
有条件的话可以加入discord服务器https://discord.gg/ucH3mgeWpQ
@@ -15,12 +23,12 @@ Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed
## 🚀 安装 / Installation
> 如果你的设备不支持ipv6请下载末尾带有`ipv4`的压缩包,正常情况下请尽量下载普通包(服务器速度快)
目前 Sanp.Hutao.Rev 更新了打包方式,并采用了标准现代的 msi 安装,方便程序获取管理员权限和更多的功能设置,不再需要原 Depolyment
只有`.msi`安装包安装的可以和之前的版本共存,如果通过`.msix`安装包安装则可能出现`0x80073CF3`,备份旧版本数据文件夹后卸载旧版本即可继续安装,将旧版本数据文件夹里面的文件复制到该版本的数据文件夹中即可恢复数据
有时候我们在对某些功能有重大更改时发布测试版可在官网的下载可加入discord服务器报告功能使用情况和获取测试通知
---
## 开发
@@ -34,7 +42,7 @@ Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed
**目前元数据的编写进度:**
| 项目V6.2 | 是否完成 |
| 项目V6.3 | 是否完成 |
| ----------- | ----------- |
| 总体数据 | ✔️ |
@@ -52,40 +60,55 @@ Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed
https://deepwiki.com/DGP-Studio/Snap.Hutao
https://deepwiki.com/DGP-Studio/Snap.Hutao.Server
**该项目所需的其他仓库,欢迎贡献或者自部署**
- 元数据:[Snap.Metadata](https://github.com/wangdage12/Snap.Metadata)
- 服务端:[Snap.Server](https://github.com/wangdage12/Snap.Server)
- Web管理后台和官网[Snap.Server.Web](https://github.com/wangdage12/Snap.Server.Web)
## 打包测试
由于采用了 wix 进行打包程序VS 需要安装 **HeatWave for VS2022**2026兼容。需要 msi 安装包时,右键选中 Snap.Hutao.Installer 生成后即可在目标目录找到。默认目录Snap.Hutao.Installer\bin\x64\Release\en-US\Snap.Hutao.Installer.msi
### 资源
## 资源和服务器状态
> 注意普通包的资源服务器只能使用ipv6连接也就是说你的电脑必须有ipv6并且建议你手动配置DNS为`223.5.5.5`
> 如果你的设备不支持ipv6请下载末尾带有`ipv4`的压缩包
> 由于数据文件夹中有元数据的仓库和图片缓存,才得以恢复资源文件
> 如果你发现之前版本可以显示的图片不能显示了,请查找旧数据文件夹
> `C:\Users\<用户名>\AppData\Local\Packages\xxxDGPStudio.SnapHutao_xxx\LocalCache\ImageCache`
> 并将`ImageCache`文件夹提供给我,我会尽力恢复资源
[服务器状态页面](http://serverjp.wdg.cloudns.ch:3001/status/hts)
<a href="https://uptimerobot.com" target="_blank" rel="noopener">
<picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://raw.githubusercontent.com/wangdage12/wangdage12/main/assets/uptimerobot-logo.svg">
<img alt="logo"
src="https://raw.githubusercontent.com/wangdage12/wangdage12/main/assets/uptimerobot-logo-dark.svg" width="300">
</picture>
</a>
我们将使用[UptimeRobot](https://uptimerobot.com)赞助的监控服务作为新的服务器状态页面,它有更多的功能
[新服务器状态页面](https://stats.uptimerobot.com/fHxWxdxK61)
[旧服务器状态页面](http://serverjp.wdg.cloudns.ch:3001/status/hts)
---
**元数据仓库:**
https://github.com/wangdage12/Snap.Metadata
镜像:
仓库镜像:
![http://serverjp.wdg.cloudns.ch:3001/api/badge/11/status?style=flat-square](http://serverjp.wdg.cloudns.ch:3001/api/badge/11/status?style=flat-square)
http://htgit.wdg.cloudns.ch/wdg1122/Snap.Metadata
---
**临时API**
**API**
![http://serverjp.wdg.cloudns.ch:3001/api/badge/10/status?style=flat-square](http://serverjp.wdg.cloudns.ch:3001/api/badge/10/status?style=flat-square)
https://htserver.wdg.cloudns.ch/api/
---
**临时资源站:**
**图片资源站:**
https://htserver.wdg.cloudns.ch/

Binary file not shown.

View File

@@ -4,5 +4,8 @@ This file contains the declaration of all the localizable strings.
<WixLocalization xmlns="http://wixtoolset.org/schemas/v4/wxl" Culture="en-US">
<String Id="DowngradeError" Value="A newer version of [ProductName] is already installed." />
<String Id="MainAppTitle" Value="Snap.Hutao" />
<String Id="DesktopShortcutTitle" Value="Desktop Shortcut" />
<String Id="StartMenuShortcutTitle" Value="Start Menu Shortcut" />
</WixLocalization>

View File

@@ -1,21 +1,32 @@
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package
Name="Snap.Hutao"
Manufacturer="Millennium Science Technology R-D Inst"
Version="1.17.4.0"
Version="1.18.4.0"
UpgradeCode="121203be-60cb-408f-92cc-7080f6598e68"
Language="2052"
Scope="perMachine">
<MajorUpgrade DowngradeErrorMessage="A newer version of [ProductName] is already installed." />
<Property Id="ApplicationFolderName" Value="Snap.Hutao" />
<Property Id="WixAppFolder" Value="WixPerMachineFolder" />
<MajorUpgrade DowngradeErrorMessage="!(loc.DowngradeError)" />
<MediaTemplate EmbedCab="yes" />
<Feature Id="ProductFeature" Title="Snap.Hutao" Level="1">
<ComponentGroupRef Id="MainAppComponents" />
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
<!-- 快捷方式组件 -->
<ComponentRef Id="ApplicationShortcut" />
<Feature Id="MainApp" Title="!(loc.MainAppTitle)" Level="1">
<ComponentGroupRef Id="MainAppComponents" />
</Feature>
<Feature Id="DesktopShortcutFeature" Title="!(loc.DesktopShortcutTitle)" Level="1">
<ComponentRef Id="DesktopShortcut" />
</Feature>
<Feature Id="StartMenuShortcutFeature" Title="!(loc.StartMenuShortcutTitle)" Level="1">
<ComponentRef Id="ApplicationShortcut" />
</Feature>
</Package>
<!-- 安装目录 -->

View File

@@ -0,0 +1,11 @@
<!--
This file contains the declaration of all the localizable strings.
-->
<WixLocalization xmlns="http://wixtoolset.org/schemas/v4/wxl" Culture="zh-CN">
<String Id="DowngradeError" Value="已安装更新版本的 [ProductName]。" />
<String Id="MainAppTitle" Value="Snap.Hutao" />
<String Id="DesktopShortcutTitle" Value="桌面快捷方式" />
<String Id="StartMenuShortcutTitle" Value="开始菜单快捷方式" />
</WixLocalization>

View File

@@ -4,6 +4,8 @@
<Platform>x64</Platform>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<Configuration>Release</Configuration>
<DefaultCulture>zh-CN</DefaultCulture>
<Cultures>zh-CN;en-US</Cultures>
</PropertyGroup>
<ItemGroup>
@@ -19,6 +21,13 @@
<SuppressRootDirectory>true</SuppressRootDirectory>
</HarvestDirectory>
<PackageReference Include="WixToolset.Heat" Version="4.0.1" />
<PackageReference Include="WixToolset.Heat" Version="6.0.2" />
<PackageReference Include="WixToolset.UI.wixext" Version="6.0.2" />
</ItemGroup>
<ItemGroup>
<WixLocalization Include="Package.zh-cn.wxl" />
<WixLocalization Include="Package.en-us.wxl" />
</ItemGroup>
</Project>

View File

@@ -33,6 +33,8 @@ internal static class HutaoRuntime
public static WebView2Version WebView2Version { get; } = InitializeWebView2();
public static string WebView2UserDataDirectory { get; } = InitializeWebView2UserDataDirectory();
// ⚠️ 延迟初始化以避免循环依赖
private static readonly Lazy<bool> LazyIsProcessElevated = new(GetIsProcessElevated);
@@ -144,6 +146,13 @@ internal static class HutaoRuntime
return cacheDir;
}
private static string InitializeWebView2UserDataDirectory()
{
string directory = Path.Combine(LocalCacheDirectory, "WebView2");
Directory.CreateDirectory(directory);
return directory;
}
private static bool CheckAppNotificationEnabled()
{
try

View File

@@ -23,12 +23,13 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
private readonly TargetNativeConfiguration config;
private readonly ITaskContext taskContext;
private readonly IProcess gameProcess;
private readonly bool supportsResumeMainThread;
private readonly NamedPipeServerStream serverStream;
private volatile bool disposed;
public YaeNamedPipeServer(IServiceProvider serviceProvider, IProcess gameProcess, TargetNativeConfiguration config)
public YaeNamedPipeServer(IServiceProvider serviceProvider, IProcess gameProcess, TargetNativeConfiguration config, bool supportsResumeMainThread = true)
{
Verify.Operation(HutaoRuntime.IsProcessElevated, "Snap Hutao must be elevated to use Yae.");
@@ -36,6 +37,7 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
this.gameProcess = gameProcess;
this.config = config;
this.supportsResumeMainThread = supportsResumeMainThread;
// Yae is always running elevated, so we don't need to use ACL method.
serverStream = new(PipeName);
@@ -115,8 +117,11 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
}
case YaeCommandKind.RequestResumeThread:
{
if (supportsResumeMainThread)
{
gameProcess.ResumeMainThread();
}
return default;
}

View File

@@ -126,6 +126,9 @@ internal static class AvatarIds
public static readonly AvatarId Nefer = 10000122;
public static readonly AvatarId Durin = 10000123;
public static readonly AvatarId Jahoda = 10000124;
public static readonly AvatarId Columbina = 10000125;
public static readonly AvatarId Zibai = 10000126;
public static readonly AvatarId Illuga = 10000127;
private static readonly FrozenSet<AvatarId> StandardWishIds =
[

View File

@@ -22,9 +22,8 @@ internal static class WeaponIds
11401U, 11402U, 11403U, 11405U,
12401U, 12402U, 12403U, 12405U,
13401U, 13407U,
14401U, 14402U, 14403U, 14409U,
15401U, 15402U, 15403U, 15405U,
15434U
14401U, 14402U, 14403U, 14409U, 14433U, 14434U,
15401U, 15402U, 15403U, 15405U, 15434U
];
public static readonly FrozenSet<WeaponId> OrangeStandardWishIds =
@@ -34,7 +33,8 @@ internal static class WeaponIds
13502U, 13505U,
14501U, 14502U,
15501U, 15502U,
15515U, 11518U
15515U, 11518U,
14522U, 11519U
];
public static bool IsOrangeStandardWish(in WeaponId weaponId)

View File

@@ -13,7 +13,7 @@
<Identity
Name="60568DGPStudio.SnapHutao"
Publisher="CN=35C8E923-85DF-49A7-9172-B39DC6312C52"
Version="1.18.0.0" />
Version="1.18.4.0" />
<Properties>
<DisplayName>Snap Hutao</DisplayName>

View File

@@ -37,15 +37,20 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
return;
}
// 获取unlocker.exe路径放在Snap.Hutao同一目录
string hutaoDirectory = AppContext.BaseDirectory;
unlockerPath = Path.Combine(hutaoDirectory, UnlockerExecutableName);
// 准备 unlocker.exe 到可写的应用数据目录
await PrepareUnlockerToDataDirectoryAsync().ConfigureAwait(false);
// 从应用数据目录获取 unlocker.exe 路径
unlockerPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerExecutableName);
if (!File.Exists(unlockerPath))
{
throw HutaoException.InvalidOperation("未找到unlockfps.exe文件请将genshin-fps-unlock-master编译后的unlockfps.exe放置在Snap.Hutao同目录下");
}
// 添加到 Windows Defender 排除项(需要管理员权限)
await AddToDefenderExclusionAsync(unlockerPath).ConfigureAwait(false);
// 获取游戏路径
gamePath = context.FileSystem.GameFilePath;
@@ -81,6 +86,134 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
await MonitorUnlockerProcessAsync(context, token).ConfigureAwait(false);
}
private async ValueTask PrepareUnlockerToDataDirectoryAsync()
{
// 数据目录中的目标路径
string dataDirectoryUnlockerPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerExecutableName);
// 安装目录中的源路径
string installDirectoryUnlockerPath = Path.Combine(AppContext.BaseDirectory, UnlockerExecutableName);
// 检查是否需要复制
bool needsCopy = false;
if (!File.Exists(dataDirectoryUnlockerPath))
{
needsCopy = true;
}
else
{
// 比较文件大小和修改时间,如果不同则更新
var sourceInfo = new FileInfo(installDirectoryUnlockerPath);
var targetInfo = new FileInfo(dataDirectoryUnlockerPath);
if (sourceInfo.Length != targetInfo.Length || sourceInfo.LastWriteTime > targetInfo.LastWriteTime)
{
needsCopy = true;
}
}
// 如果需要复制,执行复制操作
if (needsCopy)
{
try
{
Directory.CreateDirectory(HutaoRuntime.DataDirectory);
File.Copy(installDirectoryUnlockerPath, dataDirectoryUnlockerPath, true);
SentrySdk.AddBreadcrumb(
$"unlockfps.exe 已复制到数据目录: {dataDirectoryUnlockerPath}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
}
catch (Exception ex)
{
throw HutaoException.InvalidOperation($"复制 unlockfps.exe 到数据目录失败: {ex.Message}", ex);
}
}
}
private async ValueTask AddToDefenderExclusionAsync(string executablePath)
{
try
{
// 检查是否已经在排除项中
ProcessStartInfo checkInfo = new()
{
FileName = "powershell.exe",
Arguments = $"-Command \"(Get-MpPreference).ExclusionPath -split '\"' | Where-Object {{ $_ -eq '{executablePath}' }}\"",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden,
};
using (Process checkProcess = new() { StartInfo = checkInfo })
{
checkProcess.Start();
string output = await checkProcess.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
await checkProcess.WaitForExitAsync().ConfigureAwait(false);
// 如果输出包含路径,说明已经在排除项中
if (!string.IsNullOrWhiteSpace(output) && output.Trim().Contains(executablePath))
{
SentrySdk.AddBreadcrumb(
$"unlockfps.exe 已在 Windows Defender 排除项中",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
return;
}
}
// 不在排除项中,尝试添加
ProcessStartInfo addInfo = new()
{
FileName = "powershell.exe",
Arguments = $"-Command \"Add-MpPreference -ExclusionPath '{executablePath}'\"",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
WindowStyle = ProcessWindowStyle.Hidden,
Verb = "runas", // 请求管理员权限
};
using (Process addProcess = new() { StartInfo = addInfo })
{
addProcess.Start();
string output = await addProcess.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
string error = await addProcess.StandardError.ReadToEndAsync().ConfigureAwait(false);
await addProcess.WaitForExitAsync().ConfigureAwait(false);
if (addProcess.ExitCode == 0)
{
SentrySdk.AddBreadcrumb(
$"unlockfps.exe 已成功添加到 Windows Defender 排除项",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
}
else
{
SentrySdk.AddBreadcrumb(
$"无法添加到 Windows Defender 排除项(需要管理员权限): {error ?? output}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Warning);
}
}
}
catch (Exception ex)
{
SentrySdk.AddBreadcrumb(
$"添加 Windows Defender 排除项失败: {ex.Message}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Warning);
}
}
private async ValueTask CreateUnlockerConfigAsync(BeforeLaunchExecutionContext context)
{
if (string.IsNullOrEmpty(gamePath))
@@ -88,8 +221,8 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
throw HutaoException.NotSupported("游戏路径未初始化");
}
// 直接在unlocker同目录创建配置文件
string unlockerConfigPath = Path.Combine(Path.GetDirectoryName(unlockerPath)!, UnlockerConfigName);
// 在应用数据目录创建配置文件
string unlockerConfigPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerConfigName);
int targetFps = context.LaunchOptions.TargetFps.Value;
string configContent = $"[Setting]\nPath={gamePath}\nFPS={targetFps}";
@@ -126,7 +259,7 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
try
{
string configPath = Path.Combine(Path.GetDirectoryName(unlockerPath)!, UnlockerConfigName);
string configPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerConfigName);
if (!File.Exists(configPath))
{
throw HutaoException.InvalidOperation($"配置文件不存在: {configPath}");
@@ -139,6 +272,13 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
// 构建游戏启动参数,传递给 unlockfps.exe
string gameArguments = BuildGameArguments(context);
SentrySdk.AddBreadcrumb(
$"Game arguments for unlocker: {gameArguments}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
ProcessStartInfo startInfo = new()
{
FileName = unlockerPath,
@@ -148,6 +288,7 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
RedirectStandardOutput = true,
RedirectStandardError = true,
WindowStyle = ProcessWindowStyle.Normal,
Arguments = gameArguments,
};
unlockerProcess = new Process { StartInfo = startInfo };
@@ -197,6 +338,65 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
}
}
private string BuildGameArguments(BeforeLaunchExecutionContext context)
{
LaunchOptions launchOptions = context.LaunchOptions;
if (!launchOptions.AreCommandLineArgumentsEnabled.Value)
{
return string.Empty;
}
// 获取米游社登录Ticket
string? authTicket = default;
bool useAuthTicket = launchOptions.UsingHoyolabAccount.Value
&& context.TryGetOption(LaunchExecutionOptionsKey.LoginAuthTicket, out authTicket)
&& !string.IsNullOrEmpty(authTicket);
StringBuilder arguments = new();
// 构建与 GameProcessFactory.CreateForDefault 相同的命令行参数
if (launchOptions.IsBorderless.Value)
{
arguments.Append(" -popupwindow");
}
if (launchOptions.IsExclusive.Value)
{
arguments.Append(" -window-mode exclusive");
}
arguments.Append($" -screen-fullscreen {(launchOptions.IsFullScreen.Value ? "1" : "0")}");
if (launchOptions.IsScreenWidthEnabled.Value)
{
arguments.Append($" -screen-width {launchOptions.ScreenWidth.Value}");
}
if (launchOptions.IsScreenHeightEnabled.Value)
{
arguments.Append($" -screen-height {launchOptions.ScreenHeight.Value}");
}
if (launchOptions.IsMonitorEnabled.Value)
{
arguments.Append($" -monitor {launchOptions.Monitor.Value?.Value ?? 1}");
}
if (launchOptions.IsPlatformTypeEnabled.Value)
{
arguments.Append($" -platform_type {launchOptions.PlatformType.Value:G}");
}
// 添加米游社登录参数
if (useAuthTicket)
{
arguments.Append($" login_auth_ticket={authTicket}");
}
return arguments.ToString();
}
private async ValueTask MonitorExistingUnlockerAsync(LaunchExecutionContext context, CancellationToken token)
{
// 恢复模式下,检查是否有解锁器进程在运行
@@ -247,7 +447,7 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
try
{
string configPath = Path.Combine(Path.GetDirectoryName(unlockerPath)!, UnlockerConfigName);
string configPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerConfigName);
if (File.Exists(configPath))
{
string[] lines = await File.ReadAllLinesAsync(configPath).ConfigureAwait(false);

View File

@@ -26,17 +26,22 @@ internal sealed class LaunchExecutionGameProcessStartHandler : AbstractLaunchExe
public override async ValueTask ExecuteAsync(LaunchExecutionContext context)
{
// 如果启用了IslandFPS解锁则跳过启动游戏进程
// 因为unlockfps.exe会负责启动游戏
if (context.LaunchOptions.IsIslandEnabled.Value)
{
context.Progress.Report(new(SH.ServiceGameLaunchPhaseProcessStarted));
return;
}
try
{
// 对于suspended进程Yae注入模式、Island模式需要先Start()创建进程然后ResumeMainThread()恢复主线程
// 对于正常启动的进程ShellExecute、DiagnosticsProcess只调用Start()
context.Process.Start();
// 尝试恢复主线程适用于suspended进程
try
{
context.Process.ResumeMainThread();
}
catch (HutaoException ex) when (ex.Message.Contains("ResumeMainThread is not supported"))
{
// ResumeMainThread不支持说明是正常启动的进程DiagnosticsProcess忽略此错误
}
await context.TaskContext.SwitchToMainThreadAsync();
GameLifeCycle.IsGameRunningProperty.Value = true;
}

View File

@@ -2,8 +2,11 @@
// Licensed under the MIT license.
using Snap.Hutao.Core;
using Snap.Hutao.Core.Diagnostics;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Core.LifeCycle.InterProcess.Yae;
using Snap.Hutao.Factory.Process;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Island;
using Snap.Hutao.Service.Game.Launching.Context;
using Snap.Hutao.Service.Yae.Achievement;
@@ -32,36 +35,57 @@ internal sealed class LaunchExecutionYaeNamedPipeHandler : AbstractLaunchExecuti
HutaoException.NotSupported(SH.ServiceGameLaunchingHandlerEmbeddedYaeClientNotElevated);
}
if (!context.LaunchOptions.IsIslandEnabled.Value)
{
context.Process.Kill();
HutaoException.NotSupported(SH.ServiceGameLaunchingHandlerEmbeddedYaeIslandNotEnabled);
return;
}
string dataFolderYaePath = Path.Combine(HutaoRuntime.DataDirectory, "YaeAchievementLib.dll");
InstalledLocation.CopyFileFromApplicationUri("ms-appx:///YaeAchievementLib.dll", dataFolderYaePath);
// 直接使用创建的游戏进程
int actualProcessId = context.Process.Id;
if (actualProcessId == 0)
{
throw HutaoException.Throw("游戏进程未正确创建");
}
try
{
DllInjectionUtilities.InjectUsingRemoteThread(dataFolderYaePath, "YaeMain", context.Process.Id);
DllInjectionUtilities.InjectUsingRemoteThread(dataFolderYaePath, "YaeMain", actualProcessId);
}
catch (Exception ex)
{
// Windows Defender Application Control
if (HutaoNative.IsWin32(ex.HResult, WIN32_ERROR.ERROR_SYSTEM_INTEGRITY_POLICY_VIOLATION))
{
context.Process.Kill();
throw HutaoException.Throw(SH.ServiceGameLaunchingHandlerEmbeddedYaeErrorSystemIntegrityPolicyViolation);
}
throw;
// Access Denied (0x80070005) - 权限不足,无法在远程进程中分配内存
if (ex.HResult == unchecked((int)0x80070005))
{
throw HutaoException.Throw($"无法在游戏进程中注入 DLL (访问被拒绝)。\n\n" +
$"可能的原因:\n" +
$"1. 游戏进程的完整性级别高于 Snap Hutao\n" +
$"2. Windows Defender 或其他安全软件阻止了注入\n" +
$"解决方法:\n" +
$"1. 检查 Windows Defender 设置,将 Snap Hutao 添加到排除列表\n" +
$"2. 以管理员身份运行 Snap Hutao\n" +
$"3. 检查是否有其他安全软件(如 360、火绒等干扰");
}
// 游戏进程由直接启动,已经是运行状态
// InjectUsingWindowsHook2 需要手动恢复主线程,但 DiagnosticsProcess 不支持 ResumeMainThread
// 这里不使用 InjectUsingWindowsHook2
throw new InvalidOperationException($"无法注入 DLL: {ex.Message}. 请确保没有启用 Windows Defender Application Control 或其他安全限制。", ex);
}
try
{
// 获取游戏进程用于命名管道服务器
IProcess actualProcess = ProcessFactory.TryGetById(actualProcessId, out IProcess? process)
? process
: throw HutaoException.Throw($"无法获取进程 ID {actualProcessId}");
// 已经是运行状态,不需要恢复主线程
#pragma warning disable CA2007
await using (YaeNamedPipeServer server = new(context.ServiceProvider, context.Process, config))
await using (YaeNamedPipeServer server = new(context.ServiceProvider, actualProcess, config, supportsResumeMainThread: false))
#pragma warning restore CA2007
{
receiver.Array = await server.GetDataArrayAsync().ConfigureAwait(false);
@@ -69,7 +93,6 @@ internal sealed class LaunchExecutionYaeNamedPipeHandler : AbstractLaunchExecuti
}
catch (Exception)
{
context.Process.Kill();
throw;
}
}

View File

@@ -21,6 +21,8 @@ internal abstract class AbstractLaunchExecutionInvoker
private bool invoked;
protected ImmutableArray<ILaunchExecutionHandler> Handlers { get; init; }
protected virtual bool ShouldWaitForProcessExit { get => true; }
protected virtual bool ShouldSpinWaitGameExitAfterInvoke { get => true; }
public static bool Invoking()
{
@@ -40,7 +42,7 @@ internal abstract class AbstractLaunchExecutionInvoker
finally
{
Invokers.TryRemove(this, out _);
if (!Invoking())
if (!Invoking() && ShouldSpinWaitGameExitAfterInvoke)
{
await GameLifeCycle.SpinWaitGameExitAsync(taskContext).ConfigureAwait(false);
}
@@ -132,7 +134,7 @@ internal abstract class AbstractLaunchExecutionInvoker
}
// 只有在没有启用Island且进程存在时才等待退出
if (process is { IsRunning: true })
if (ShouldWaitForProcessExit && process is { IsRunning: true })
{
progress.Report(new(SH.ServiceGameLaunchPhaseWaitingProcessExit));
try
@@ -148,7 +150,7 @@ internal abstract class AbstractLaunchExecutionInvoker
return;
}
}
else if (beforeContext.LaunchOptions.IsIslandEnabled.Value)
else if (ShouldWaitForProcessExit && beforeContext.LaunchOptions.IsIslandEnabled.Value)
{
progress.Report(new(SH.ServiceGameLaunchPhaseWaitingProcessExit));
await taskContext.SwitchToBackgroundAsync();

View File

@@ -9,6 +9,9 @@ namespace Snap.Hutao.Service.Game.Launching.Invoker;
internal sealed class ConvertOnlyLaunchExecutionInvoker : AbstractLaunchExecutionInvoker
{
protected override bool ShouldWaitForProcessExit { get => false; }
protected override bool ShouldSpinWaitGameExitAfterInvoke { get => false; }
public ConvertOnlyLaunchExecutionInvoker()
{
Handlers =

View File

@@ -2,9 +2,14 @@
// Licensed under the MIT license.
using Snap.Hutao.Core.Diagnostics;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Core.LifeCycle.InterProcess.Yae;
using Snap.Hutao.Factory.Progress;
using Snap.Hutao.Factory.Process;
using Snap.Hutao.Service.Game.FileSystem;
using Snap.Hutao.Service.Game.Launching.Context;
using Snap.Hutao.Service.Game.Launching.Handler;
using Snap.Hutao.Service.Game.Package;
using Snap.Hutao.Service.Yae.Achievement;
namespace Snap.Hutao.Service.Game.Launching.Invoker;
@@ -28,4 +33,99 @@ internal sealed class YaeLaunchExecutionInvoker : AbstractLaunchExecutionInvoker
{
return GameProcessFactory.CreateForEmbeddedYae(beforeContext);
}
protected override bool ShouldWaitForProcessExit { get => false; }
protected override bool ShouldSpinWaitGameExitAfterInvoke { get => false; }
public async ValueTask InvokeAsync(LaunchExecutionInvocationContext context)
{
ITaskContext taskContext = context.ServiceProvider.GetRequiredService<ITaskContext>();
string lockTrace = $"{GetType().Name}.{nameof(InvokeAsync)}";
context.LaunchOptions.TryGetGameFileSystem(lockTrace, out IGameFileSystem? gameFileSystem);
ArgumentNullException.ThrowIfNull(gameFileSystem);
using (GameFileSystemReference fileSystemReference = new(gameFileSystem))
{
if (context.ViewModel.TargetScheme is not { } targetScheme)
{
throw HutaoException.InvalidOperation(SH.ViewModelLaunchGameSchemeNotSelected);
}
if (context.ViewModel.CurrentScheme is not { } currentScheme)
{
throw HutaoException.InvalidOperation(SH.ServiceGameLaunchExecutionCurrentSchemeNull);
}
IProgress<LaunchStatus?> progress = CreateStatusProgress(context.ServiceProvider);
BeforeLaunchExecutionContext beforeContext = new()
{
ViewModel = context.ViewModel,
Progress = progress,
ServiceProvider = context.ServiceProvider,
TaskContext = taskContext,
FileSystem = fileSystemReference,
HoyoPlay = context.ServiceProvider.GetRequiredService<IHoyoPlayService>(),
Messenger = context.ServiceProvider.GetRequiredService<IMessenger>(),
LaunchOptions = context.LaunchOptions,
CurrentScheme = currentScheme,
TargetScheme = targetScheme,
Identity = context.Identity,
};
foreach (ILaunchExecutionHandler handler in Handlers)
{
await handler.BeforeAsync(beforeContext).ConfigureAwait(false);
}
fileSystemReference.Exchange(beforeContext.FileSystem);
// Yae注入功能不依赖unlockfps.exe总是创建游戏进程
IProcess? process = CreateProcess(beforeContext);
using (process)
{
if (process is null)
{
return;
}
LaunchExecutionContext executionContext = new()
{
Progress = progress,
ServiceProvider = context.ServiceProvider,
TaskContext = taskContext,
Messenger = context.ServiceProvider.GetRequiredService<IMessenger>(),
LaunchOptions = context.LaunchOptions,
Process = process,
IsOversea = targetScheme.IsOversea,
};
foreach (ILaunchExecutionHandler handler in Handlers)
{
await handler.ExecuteAsync(executionContext).ConfigureAwait(false);
}
}
AfterLaunchExecutionContext afterContext = new()
{
ServiceProvider = context.ServiceProvider,
TaskContext = taskContext,
};
foreach (ILaunchExecutionHandler handler in Handlers)
{
await handler.AfterAsync(afterContext).ConfigureAwait(false);
}
}
}
private static IProgress<LaunchStatus?> CreateStatusProgress(IServiceProvider serviceProvider)
{
IProgressFactory progressFactory = serviceProvider.GetRequiredService<IProgressFactory>();
LaunchStatusOptions options = serviceProvider.GetRequiredService<LaunchStatusOptions>();
return progressFactory.CreateForMainThread<LaunchStatus?, LaunchStatusOptions>(static (status, options) => options.LaunchStatus = status, options);
}
}

View File

@@ -47,6 +47,9 @@ internal sealed partial class HutaoAsAService : IHutaoAsAService
}
}
// Filter announcements by Distribution
array = [.. array.Where(a => string.IsNullOrEmpty(a.Distribution) || a.Distribution == "Snap Hutao")]; // 请自行修改发行版名称
foreach (HutaoAnnouncement item in array)
{
item.DismissCommand = dismissCommand;

View File

@@ -29,6 +29,26 @@ internal sealed class TargetNativeConfiguration
public required uint Decompress { get; init; }
public static TargetNativeConfiguration Create(uint storeCmdId, uint achievementCmdId, MethodRva methodRva)
{
return new()
{
StoreCmdId = storeCmdId,
AchievementCmdId = achievementCmdId,
DoCmd = methodRva.DoCmd,
UpdateNormalProperty = methodRva.UpdateNormalProperty,
NewString = methodRva.NewString,
FindGameObject = methodRva.FindGameObject,
EventSystemUpdate = methodRva.EventSystemUpdate,
SimulatePointerClick = methodRva.SimulatePointerClick,
ToInt32 = methodRva.ToInt32,
TcpStatePtr = methodRva.TcpStatePtr,
SharedInfoPtr = methodRva.SharedInfoPtr,
Decompress = methodRva.Decompress,
};
}
public static TargetNativeConfiguration Create(NativeConfiguration config, bool isOversea)
{
MethodRva methodRva = isOversea ? config.MethodRva.Oversea : config.MethodRva.Chinese;

View File

@@ -0,0 +1,32 @@
namespace Snap.Hutao.Service.Yae.Metadata;
internal static class Crc32
{
private const uint Polynomial = 0xEDB88320;
private static readonly uint[] Crc32Table = new uint[256];
static Crc32()
{
for (uint i = 0; i < Crc32Table.Length; i++)
{
uint value = i;
for (int j = 0; j < 8; j++)
{
value = (value >> 1) ^ ((value & 1) * Polynomial);
}
Crc32Table[i] = value;
}
}
public static uint Compute(Span<byte> buffer)
{
uint checksum = 0xFFFFFFFF;
foreach (byte b in buffer)
{
checksum = (checksum >> 8) ^ Crc32Table[(b ^ checksum) & 0xFF];
}
return ~checksum;
}
}

View File

@@ -0,0 +1,6 @@
namespace Snap.Hutao.Service.Yae.Metadata;
internal interface IYaeMetadataService
{
ValueTask<YaeNativeLibConfig?> GetNativeLibConfigAsync(CancellationToken token = default);
}

View File

@@ -0,0 +1,188 @@
using Google.Protobuf;
using Snap.Hutao.Core.Protobuf;
using Snap.Hutao.Service.Yae.Achievement;
namespace Snap.Hutao.Service.Yae.Metadata;
internal static class YaeMetadataParser
{
private const uint AchievementInfoNativeConfigTag = 42; // field 5, wire type length-delimited
private const uint NativeConfigStoreCmdIdTag = 8; // field 1, varint
private const uint NativeConfigAchievementCmdIdTag = 16; // field 2, varint
private const uint NativeConfigMethodRvaTag = 82; // field 10, length-delimited
private const uint MapEntryKeyTag = 8; // field 1, varint
private const uint MapEntryValueTag = 18; // field 2, length-delimited
private const uint MethodRvaDoCmdTag = 8;
private const uint MethodRvaUpdateNormalPropTag = 24;
private const uint MethodRvaNewStringTag = 32;
private const uint MethodRvaFindGameObjectTag = 40;
private const uint MethodRvaEventSystemUpdateTag = 48;
private const uint MethodRvaSimulatePointerClickTag = 56;
private const uint MethodRvaToInt32Tag = 64;
private const uint MethodRvaTcpStatePtrTag = 72;
private const uint MethodRvaSharedInfoPtrTag = 80;
private const uint MethodRvaDecompressTag = 88;
public static YaeNativeLibConfig? ParseNativeLibConfig(byte[] data)
{
uint storeCmdId = 0;
uint achievementCmdId = 0;
Dictionary<uint, MethodRva> methodRva = [];
bool hasNativeConfig = false;
CodedInputStream input = new(data);
while (input.TryReadTag(out uint tag))
{
switch (tag)
{
case AchievementInfoNativeConfigTag:
hasNativeConfig = true;
using (CodedInputStream nativeConfigStream = input.UnsafeReadLengthDelimitedStream())
{
ParseNativeConfig(nativeConfigStream, ref storeCmdId, ref achievementCmdId, methodRva);
}
break;
default:
input.SkipLastField();
break;
}
}
if (!hasNativeConfig || methodRva.Count == 0)
{
return null;
}
return new YaeNativeLibConfig
{
StoreCmdId = storeCmdId,
AchievementCmdId = achievementCmdId,
MethodRva = methodRva,
};
}
private static void ParseNativeConfig(CodedInputStream input, ref uint storeCmdId, ref uint achievementCmdId, Dictionary<uint, MethodRva> methodRva)
{
while (input.TryReadTag(out uint tag))
{
switch (tag)
{
case NativeConfigStoreCmdIdTag:
storeCmdId = input.ReadUInt32();
break;
case NativeConfigAchievementCmdIdTag:
achievementCmdId = input.ReadUInt32();
break;
case NativeConfigMethodRvaTag:
using (CodedInputStream entryStream = input.UnsafeReadLengthDelimitedStream())
{
ParseMethodRvaEntry(entryStream, methodRva);
}
break;
default:
input.SkipLastField();
break;
}
}
}
private static void ParseMethodRvaEntry(CodedInputStream input, Dictionary<uint, MethodRva> methodRva)
{
uint key = 0;
MethodRva? value = null;
while (input.TryReadTag(out uint tag))
{
switch (tag)
{
case MapEntryKeyTag:
key = input.ReadUInt32();
break;
case MapEntryValueTag:
using (CodedInputStream valueStream = input.UnsafeReadLengthDelimitedStream())
{
value = ParseMethodRvaConfig(valueStream);
}
break;
default:
input.SkipLastField();
break;
}
}
if (value is not null)
{
methodRva[key] = value;
}
}
private static MethodRva ParseMethodRvaConfig(CodedInputStream input)
{
uint doCmd = 0;
uint updateNormalProp = 0;
uint newString = 0;
uint findGameObject = 0;
uint eventSystemUpdate = 0;
uint simulatePointerClick = 0;
uint toInt32 = 0;
uint tcpStatePtr = 0;
uint sharedInfoPtr = 0;
uint decompress = 0;
while (input.TryReadTag(out uint tag))
{
switch (tag)
{
case MethodRvaDoCmdTag:
doCmd = input.ReadUInt32();
break;
case MethodRvaUpdateNormalPropTag:
updateNormalProp = input.ReadUInt32();
break;
case MethodRvaNewStringTag:
newString = input.ReadUInt32();
break;
case MethodRvaFindGameObjectTag:
findGameObject = input.ReadUInt32();
break;
case MethodRvaEventSystemUpdateTag:
eventSystemUpdate = input.ReadUInt32();
break;
case MethodRvaSimulatePointerClickTag:
simulatePointerClick = input.ReadUInt32();
break;
case MethodRvaToInt32Tag:
toInt32 = input.ReadUInt32();
break;
case MethodRvaTcpStatePtrTag:
tcpStatePtr = input.ReadUInt32();
break;
case MethodRvaSharedInfoPtrTag:
sharedInfoPtr = input.ReadUInt32();
break;
case MethodRvaDecompressTag:
decompress = input.ReadUInt32();
break;
default:
input.SkipLastField();
break;
}
}
return new MethodRva
{
DoCmd = doCmd,
UpdateNormalProperty = updateNormalProp,
NewString = newString,
FindGameObject = findGameObject,
EventSystemUpdate = eventSystemUpdate,
SimulatePointerClick = simulatePointerClick,
ToInt32 = toInt32,
TcpStatePtr = tcpStatePtr,
SharedInfoPtr = sharedInfoPtr,
Decompress = decompress,
};
}
}

View File

@@ -0,0 +1,65 @@
using Microsoft.Extensions.Caching.Memory;
using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
using System.IO;
using System.Net.Http;
namespace Snap.Hutao.Service.Yae.Metadata;
[Service(ServiceLifetime.Singleton, typeof(IYaeMetadataService))]
[HttpClient(HttpClientConfiguration.Default)]
internal sealed partial class YaeMetadataService : IYaeMetadataService
{
private const string MetadataUrl = "https://rin.holohat.work/schicksal/metadata";
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(6);
private static readonly string? LocalMetadataPath = TryGetLocalMetadataPath();
private readonly IHttpClientFactory httpClientFactory;
private readonly IMemoryCache memoryCache;
[GeneratedConstructor]
public partial YaeMetadataService(IServiceProvider serviceProvider);
public ValueTask<YaeNativeLibConfig?> GetNativeLibConfigAsync(CancellationToken token = default)
{
Task<YaeNativeLibConfig?> task = memoryCache.GetOrCreateAsync($"{nameof(YaeMetadataService)}.NativeLibConfig", async entry =>
{
entry.SetSlidingExpiration(CacheDuration);
byte[] data;
if (!string.IsNullOrEmpty(LocalMetadataPath) && File.Exists(LocalMetadataPath))
{
data = await File.ReadAllBytesAsync(LocalMetadataPath, token).ConfigureAwait(false);
if (data.Length > 0)
{
return YaeMetadataParser.ParseNativeLibConfig(data);
}
}
using HttpClient httpClient = httpClientFactory.CreateClient(nameof(YaeMetadataService));
data = await httpClient.GetByteArrayAsync(MetadataUrl, token).ConfigureAwait(false);
return YaeMetadataParser.ParseNativeLibConfig(data);
});
return new ValueTask<YaeNativeLibConfig?>(task);
}
private static string? TryGetLocalMetadataPath()
{
try
{
// 尝试获取用户下载目录下的metadata文件本地测试和排查问题时使用
string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(userProfile))
{
return default;
}
string localPath = Path.Combine(userProfile, "Downloads", "metadata");
return localPath;
}
catch
{
return default;
}
}
}

View File

@@ -0,0 +1,12 @@
using Snap.Hutao.Service.Yae.Achievement;
namespace Snap.Hutao.Service.Yae.Metadata;
internal sealed class YaeNativeLibConfig
{
public required uint StoreCmdId { get; init; }
public required uint AchievementCmdId { get; init; }
public required IReadOnlyDictionary<uint, MethodRva> MethodRva { get; init; }
}

View File

@@ -6,7 +6,6 @@ using Snap.Hutao.Core.LifeCycle.InterProcess.Yae;
using Snap.Hutao.Factory.ContentDialog;
using Snap.Hutao.Model.InterChange.Achievement;
using Snap.Hutao.Model.InterChange.Inventory;
using Snap.Hutao.Service.Feature;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.FileSystem;
using Snap.Hutao.Service.Game.Launching;
@@ -15,10 +14,12 @@ using Snap.Hutao.Service.Game.Launching.Invoker;
using Snap.Hutao.Service.Notification;
using Snap.Hutao.Service.User;
using Snap.Hutao.Service.Yae.Achievement;
using Snap.Hutao.Service.Yae.Metadata;
using Snap.Hutao.Service.Yae.PlayerStore;
using Snap.Hutao.ViewModel.Game;
using Snap.Hutao.ViewModel.User;
using System.Diagnostics;
using System.IO;
namespace Snap.Hutao.Service.Yae;
@@ -27,7 +28,7 @@ internal sealed partial class YaeService : IYaeService
{
private readonly IContentDialogFactory contentDialogFactory;
private readonly IServiceProvider serviceProvider;
private readonly IFeatureService featureService;
private readonly IYaeMetadataService yaeMetadataService;
private readonly IUserService userService;
private readonly ITaskContext taskContext;
private readonly IMessenger messenger;
@@ -57,15 +58,12 @@ internal sealed partial class YaeService : IYaeService
Identity = GameIdentity.Create(userAndUid, viewModel.GameAccount),
};
if (!TryGetGameVersion(context, out string? version, out bool isOversea))
TargetNativeConfiguration? config = await TryGetTargetNativeConfigurationAsync(context).ConfigureAwait(false);
if (config is null)
{
return default;
}
AchievementFieldId? fieldId = await featureService.GetAchievementFieldIdAsync(version).ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(fieldId);
TargetNativeConfiguration config = TargetNativeConfiguration.Create(fieldId.NativeConfig, isOversea);
await new YaeLaunchExecutionInvoker(config, receiver).InvokeAsync(context).ConfigureAwait(false);
UIAF? uiaf = default;
@@ -76,7 +74,7 @@ internal sealed partial class YaeService : IYaeService
if (data.Kind is YaeCommandKind.ResponseAchievement)
{
Debug.Assert(uiaf is null);
uiaf = AchievementParser.Parse(data.Bytes, fieldId);
uiaf = AchievementParser.Parse(data.Bytes);
}
}
}
@@ -116,15 +114,12 @@ internal sealed partial class YaeService : IYaeService
Identity = GameIdentity.Create(userAndUid, viewModel.GameAccount),
};
if (!TryGetGameVersion(context, out string? version, out bool isOversea))
TargetNativeConfiguration? config = await TryGetTargetNativeConfigurationAsync(context).ConfigureAwait(false);
if (config is null)
{
return default;
}
AchievementFieldId? fieldId = await featureService.GetAchievementFieldIdAsync(version).ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(fieldId);
TargetNativeConfiguration config = TargetNativeConfiguration.Create(fieldId.NativeConfig, isOversea);
await new YaeLaunchExecutionInvoker(config, receiver).InvokeAsync(context).ConfigureAwait(false);
}
catch (Exception ex)
@@ -167,9 +162,9 @@ internal sealed partial class YaeService : IYaeService
}
}
private bool TryGetGameVersion(LaunchExecutionInvocationContext context, [NotNullWhen(true)] out string? version, out bool isOversea)
private async ValueTask<TargetNativeConfiguration?> TryGetTargetNativeConfigurationAsync(LaunchExecutionInvocationContext context)
{
const string LockTrace = $"{nameof(YaeService)}.{nameof(TryGetGameVersion)}";
const string LockTrace = $"{nameof(YaeService)}.{nameof(TryGetTargetNativeConfigurationAsync)}";
if (context.LaunchOptions.TryGetGameFileSystem(LockTrace, out IGameFileSystem? gameFileSystem) is not GameFileSystemErrorKind.None)
{
@@ -178,23 +173,54 @@ internal sealed partial class YaeService : IYaeService
if (gameFileSystem is null)
{
version = default;
isOversea = false;
return false;
return default;
}
using (gameFileSystem)
{
if (!gameFileSystem.TryGetGameVersion(out version) || string.IsNullOrEmpty(version))
if (!TryGetGameExecutableHash(gameFileSystem.GameFilePath, out uint hash))
{
messenger.Send(InfoBarMessage.Error(SH.ServiceYaeGetGameVersionFailed));
isOversea = false;
return default;
}
YaeNativeLibConfig? nativeConfig = await yaeMetadataService.GetNativeLibConfigAsync().ConfigureAwait(false);
if (nativeConfig is null)
{
messenger.Send(InfoBarMessage.Error(SH.ServiceYaeGetGameVersionFailed));
return default;
}
if (!nativeConfig.MethodRva.TryGetValue(hash, out MethodRva? methodRva))
{
messenger.Send(InfoBarMessage.Error(SH.ServiceYaeGetGameVersionFailed));
return default;
}
return TargetNativeConfiguration.Create(nativeConfig.StoreCmdId, nativeConfig.AchievementCmdId, methodRva);
}
}
private static bool TryGetGameExecutableHash(string gameFilePath, out uint hash)
{
try
{
Span<byte> buffer = stackalloc byte[0x10000];
using FileStream stream = File.OpenRead(gameFilePath);
int read = stream.ReadAtLeast(buffer, buffer.Length, throwOnEndOfStream: false);
if (read < buffer.Length)
{
hash = default;
return false;
}
isOversea = gameFileSystem.IsExecutableOversea;
}
hash = Crc32.Compute(buffer);
return true;
}
catch (IOException)
{
hash = default;
return false;
}
}
}

View File

@@ -11,7 +11,7 @@
<UseWinUI>true</UseWinUI>
<UseWPF>False</UseWPF>
<!-- 配置版本号 -->
<Version>1.18.0.0</Version>
<Version>1.18.4.0</Version>
<UseWindowsForms>False</UseWindowsForms>
<ImplicitUsings>False</ImplicitUsings>
@@ -79,6 +79,15 @@
<Copy SourceFiles="@(UnlockFpsExeSource)" DestinationFolder="$(OutputPath)" ContinueOnError="true" />
</Target>
<!-- 声明unlockfps.exe为项目内容确保MSIX打包时包含此文件 -->
<ItemGroup>
<Content Include="$(MSBuildThisFileDirectory)..\..\..\bin\unlockfps.exe">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Pack>true</Pack>
<PackagePath>unlockfps.exe</PackagePath>
</Content>
</ItemGroup>
<!-- Analyzer Files -->
<ItemGroup>
<AdditionalFiles Include="ApiEndpoints.csv" />

View File

@@ -20,6 +20,8 @@ internal partial class ScopedPage : Page
protected ScopedPage()
{
// Allow a small set of recent pages to be cached to reduce navigation stutter.
NavigationCacheMode = NavigationCacheMode.Enabled;
// Events/Override Methods order
// ----------------------------------------------------------------------
// Page Navigation methods:
@@ -103,6 +105,13 @@ internal partial class ScopedPage : Page
private void OnUnloaded(object sender, RoutedEventArgs e)
{
// When navigation cache is enabled, the page instance is reused.
// Do not tear down DataContext/scope here to avoid invalid state on return.
if (NavigationCacheMode != NavigationCacheMode.Disabled)
{
return;
}
// Cancel all tasks executed by the view model
viewCts.Cancel();

View File

@@ -264,7 +264,10 @@
<shuxv:UserView x:Name="UserView"/>
</NavigationView.PaneFooter>
<Frame x:Name="ContentFrame" ContentTransitions="{StaticResource NavigationThemeTransitions}"/>
<Frame
x:Name="ContentFrame"
CacheSize="5"
ContentTransitions="{StaticResource NavigationThemeTransitions}"/>
</NavigationView>
</Grid>

View File

@@ -6,6 +6,7 @@ using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input;
using Microsoft.Web.WebView2.Core;
using Snap.Hutao.Core;
using Snap.Hutao.Core.Logging;
using Snap.Hutao.Core.Setting;
using Snap.Hutao.UI.Input.LowLevel;
@@ -337,7 +338,8 @@ internal sealed partial class CompactWebView2Window : Microsoft.UI.Xaml.Window,
{
AdditionalBrowserArguments = "--do-not-de-elevate --autoplay-policy=no-user-gesture-required",
};
CoreWebView2Environment environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, null, options);
string userDataFolder = HutaoRuntime.WebView2UserDataDirectory;
CoreWebView2Environment environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, userDataFolder, options);
await WebView.EnsureCoreWebView2Async(environment);
}
catch (SEHException ex)

View File

@@ -5,6 +5,7 @@ using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.Web.WebView2.Core;
using Snap.Hutao.Core;
using Snap.Hutao.Core.Logging;
using Snap.Hutao.UI.Windowing;
using Snap.Hutao.UI.Windowing.Abstraction;
@@ -154,7 +155,8 @@ internal sealed partial class WebView2Window : Microsoft.UI.Xaml.Window,
{
AdditionalBrowserArguments = "--do-not-de-elevate",
};
CoreWebView2Environment environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, null, options);
string userDataFolder = HutaoRuntime.WebView2UserDataDirectory;
CoreWebView2Environment environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, userDataFolder, options);
await WebView.EnsureCoreWebView2Async(environment);
}
catch (SEHException)

View File

@@ -1,4 +1,5 @@
// Copyright (c) DGP Studio. All rights reserved.
// Copyright (c) Millennium-Science-Technology-R-D-Inst. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.ExceptionService;
@@ -129,20 +130,48 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel, IView
await HandleGamePathEntryChangeAsync().ConfigureAwait(false);
Shared.ResumeLaunchExecutionAsync(this).SafeForget();
// 初始化第三方工具列表
// 初始化第三方工具列表(不阻塞页面加载)
_ = InitializeThirdPartyToolsInBackgroundAsync(token);
return true;
}
private async Task InitializeThirdPartyToolsInBackgroundAsync(CancellationToken token)
{
try
{
ImmutableArray<ToolInfo> tools = await InitializeThirdPartyToolsAsync().ConfigureAwait(false);
SentrySdk.AddBreadcrumb($"Initialized {tools.Length} third party tools", category: "ThirdPartyTool");
// Yield to let navigation/UI finish first.
await Task.Yield();
if (token.IsCancellationRequested || IsViewUnloaded.Value)
{
return;
}
ImmutableArray<ToolInfo> tools = await InitializeThirdPartyToolsAsync(token).ConfigureAwait(false);
if (token.IsCancellationRequested || IsViewUnloaded.Value)
{
return;
}
await taskContext.SwitchToMainThreadAsync();
if (!token.IsCancellationRequested && !IsViewUnloaded.Value)
{
thirdPartyToolsField.Value = tools;
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
SentrySdk.AddBreadcrumb($"Failed to initialize third party tools: {ex.Message}", category: "ThirdPartyTool");
SentrySdk.CaptureException(ex);
}
return true;
}
[Command("IdentifyMonitorsCommand")]
@@ -337,7 +366,7 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel, IView
}
}
private async ValueTask<ImmutableArray<ToolInfo>> InitializeThirdPartyToolsAsync()
private async ValueTask<ImmutableArray<ToolInfo>> InitializeThirdPartyToolsAsync(CancellationToken token)
{
try
{
@@ -345,11 +374,18 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel, IView
IThirdPartyToolService thirdPartyToolService = serviceProvider.GetRequiredService<IThirdPartyToolService>();
SentrySdk.AddBreadcrumb("Got IThirdPartyToolService instance", category: "ThirdPartyTool");
// Note: service API is not cancellable; we only honor cancellation before/after the call.
token.ThrowIfCancellationRequested();
ImmutableArray<ToolInfo> tools = await thirdPartyToolService.GetToolsAsync().ConfigureAwait(false);
SentrySdk.AddBreadcrumb($"Got {tools.Length} tools from service", category: "ThirdPartyTool");
token.ThrowIfCancellationRequested();
SentrySdk.AddBreadcrumb($"Got {tools.Length} tools from service", category: "ThirdPartyTool");
return tools;
}
catch (OperationCanceledException)
{
return ImmutableArray<ToolInfo>.Empty;
}
catch (Exception ex)
{
SentrySdk.AddBreadcrumb($"Failed to initialize third party tools: {ex.Message}", category: "ThirdPartyTool");

View File

@@ -16,4 +16,6 @@ internal class UploadAnnouncement
public string Link { get; set; } = default!;
public string? MaxPresentVersion { get; set; }
public string? Distribution { get; set; }
}