From 4d2943d1c91501814604896d958486260ddf24f7 Mon Sep 17 00:00:00 2001 From: fanbook-wangdage <124357765+fanbook-wangdage@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:28:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9Eyae=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E6=88=90=E5=B0=B1=E5=8A=9F=E8=83=BD=E3=80=81?= =?UTF-8?q?=E4=BF=AE=E6=94=B9yae=E9=80=BB=E8=BE=91=20=E4=BF=AE=E5=A4=8Dmsi?= =?UTF-8?q?x=E6=B2=A1=E6=9C=89=E6=89=93=E5=8C=85=E8=A7=A3=E9=94=81?= =?UTF-8?q?=E5=99=A8=E7=9A=84=E9=97=AE=E9=A2=98=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E6=97=B6=E7=B1=B3=E6=B8=B8=E7=A4=BE=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=99=BB=E5=BD=95=E4=B8=8D=E8=B5=B7=E4=BD=9C=E7=94=A8?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Snap.Hutao.Installer/Package.wxs | 2 +- .../InterProcess/Yae/YaeNamedPipeServer.cs | 9 +- .../Snap.Hutao/Package.appxmanifest | 2 +- .../Game/Island/GameFpsUnlockInterop.cs | 167 +++++++++++++++- .../LaunchExecutionGameProcessStartHandler.cs | 21 +- .../LaunchExecutionYaeNamedPipeHandler.cs | 49 +++-- .../Invoker/YaeLaunchExecutionInvoker.cs | 100 ++++++++++ .../Achievement/TargetNativeConfiguration.cs | 22 +- .../Snap.Hutao/Service/Yae/Metadata/Crc32.cs | 32 +++ .../Yae/Metadata/IYaeMetadataService.cs | 6 + .../Service/Yae/Metadata/YaeMetadataParser.cs | 188 ++++++++++++++++++ .../Yae/Metadata/YaeMetadataService.cs | 65 ++++++ .../Yae/Metadata/YaeNativeLibConfig.cs | 12 ++ .../Snap.Hutao/Service/Yae/YaeService.cs | 74 ++++--- src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj | 11 +- 15 files changed, 698 insertions(+), 62 deletions(-) create mode 100644 src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/Crc32.cs create mode 100644 src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/IYaeMetadataService.cs create mode 100644 src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/YaeMetadataParser.cs create mode 100644 src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/YaeMetadataService.cs create mode 100644 src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/YaeNativeLibConfig.cs diff --git a/src/Snap.Hutao/Snap.Hutao.Installer/Package.wxs b/src/Snap.Hutao/Snap.Hutao.Installer/Package.wxs index e736271..a30f425 100644 --- a/src/Snap.Hutao/Snap.Hutao.Installer/Package.wxs +++ b/src/Snap.Hutao/Snap.Hutao.Installer/Package.wxs @@ -3,7 +3,7 @@ diff --git a/src/Snap.Hutao/Snap.Hutao/Core/LifeCycle/InterProcess/Yae/YaeNamedPipeServer.cs b/src/Snap.Hutao/Snap.Hutao/Core/LifeCycle/InterProcess/Yae/YaeNamedPipeServer.cs index 382aca6..5887391 100644 --- a/src/Snap.Hutao/Snap.Hutao/Core/LifeCycle/InterProcess/Yae/YaeNamedPipeServer.cs +++ b/src/Snap.Hutao/Snap.Hutao/Core/LifeCycle/InterProcess/Yae/YaeNamedPipeServer.cs @@ -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); @@ -116,7 +118,10 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable case YaeCommandKind.RequestResumeThread: { - gameProcess.ResumeMainThread(); + if (supportsResumeMainThread) + { + gameProcess.ResumeMainThread(); + } return default; } diff --git a/src/Snap.Hutao/Snap.Hutao/Package.appxmanifest b/src/Snap.Hutao/Snap.Hutao/Package.appxmanifest index 6c0d586..316bacc 100644 --- a/src/Snap.Hutao/Snap.Hutao/Package.appxmanifest +++ b/src/Snap.Hutao/Snap.Hutao/Package.appxmanifest @@ -13,7 +13,7 @@ + Version="1.18.4.0" /> Snap Hutao diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Island/GameFpsUnlockInterop.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Island/GameFpsUnlockInterop.cs index 7b87cbe..8ec4bf3 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/Island/GameFpsUnlockInterop.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Island/GameFpsUnlockInterop.cs @@ -37,25 +37,30 @@ 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; - + // 验证游戏路径 SentrySdk.AddBreadcrumb( $"Game path from Snap.Hutao: {gamePath}", category: "fps.unlocker", level: Sentry.BreadcrumbLevel.Info); - - + + if (!File.Exists(gamePath)) { throw HutaoException.InvalidOperation($"游戏文件不存在: {gamePath}"); @@ -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}"); @@ -214,6 +347,12 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable 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 相同的命令行参数 @@ -249,6 +388,12 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable arguments.Append($" -platform_type {launchOptions.PlatformType.Value:G}"); } + // 添加米游社登录参数 + if (useAuthTicket) + { + arguments.Append($" login_auth_ticket={authTicket}"); + } + return arguments.ToString(); } @@ -302,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); diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Launching/Handler/LaunchExecutionGameProcessStartHandler.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Launching/Handler/LaunchExecutionGameProcessStartHandler.cs index 44fb52f..d011f00 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/Launching/Handler/LaunchExecutionGameProcessStartHandler.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Launching/Handler/LaunchExecutionGameProcessStartHandler.cs @@ -26,17 +26,22 @@ internal sealed class LaunchExecutionGameProcessStartHandler : AbstractLaunchExe public override async ValueTask ExecuteAsync(LaunchExecutionContext context) { - // 如果启用了Island(FPS解锁),则跳过启动游戏进程 - // 因为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; } diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Launching/Handler/LaunchExecutionYaeNamedPipeHandler.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Launching/Handler/LaunchExecutionYaeNamedPipeHandler.cs index 1cd7365..eb4df20 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/Launching/Handler/LaunchExecutionYaeNamedPipeHandler.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Launching/Handler/LaunchExecutionYaeNamedPipeHandler.cs @@ -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,8 +93,7 @@ internal sealed class LaunchExecutionYaeNamedPipeHandler : AbstractLaunchExecuti } catch (Exception) { - context.Process.Kill(); throw; } } -} \ No newline at end of file +} diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Launching/Invoker/YaeLaunchExecutionInvoker.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Launching/Invoker/YaeLaunchExecutionInvoker.cs index c4a4050..a36dacc 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/Launching/Invoker/YaeLaunchExecutionInvoker.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Launching/Invoker/YaeLaunchExecutionInvoker.cs @@ -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(); + + 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 progress = CreateStatusProgress(context.ServiceProvider); + + BeforeLaunchExecutionContext beforeContext = new() + { + ViewModel = context.ViewModel, + Progress = progress, + ServiceProvider = context.ServiceProvider, + TaskContext = taskContext, + FileSystem = fileSystemReference, + HoyoPlay = context.ServiceProvider.GetRequiredService(), + Messenger = context.ServiceProvider.GetRequiredService(), + 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(), + 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 CreateStatusProgress(IServiceProvider serviceProvider) + { + IProgressFactory progressFactory = serviceProvider.GetRequiredService(); + LaunchStatusOptions options = serviceProvider.GetRequiredService(); + return progressFactory.CreateForMainThread(static (status, options) => options.LaunchStatus = status, options); + } } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Yae/Achievement/TargetNativeConfiguration.cs b/src/Snap.Hutao/Snap.Hutao/Service/Yae/Achievement/TargetNativeConfiguration.cs index 26a9f2c..00ac590 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Yae/Achievement/TargetNativeConfiguration.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Yae/Achievement/TargetNativeConfiguration.cs @@ -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; @@ -51,4 +71,4 @@ internal sealed class TargetNativeConfiguration Decompress = methodRva.Decompress, }; } -} \ No newline at end of file +} diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/Crc32.cs b/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/Crc32.cs new file mode 100644 index 0000000..a613420 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/Crc32.cs @@ -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 buffer) + { + uint checksum = 0xFFFFFFFF; + foreach (byte b in buffer) + { + checksum = (checksum >> 8) ^ Crc32Table[(b ^ checksum) & 0xFF]; + } + + return ~checksum; + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/IYaeMetadataService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/IYaeMetadataService.cs new file mode 100644 index 0000000..980fa9a --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/IYaeMetadataService.cs @@ -0,0 +1,6 @@ +namespace Snap.Hutao.Service.Yae.Metadata; + +internal interface IYaeMetadataService +{ + ValueTask GetNativeLibConfigAsync(CancellationToken token = default); +} diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/YaeMetadataParser.cs b/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/YaeMetadataParser.cs new file mode 100644 index 0000000..d59338b --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/YaeMetadataParser.cs @@ -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 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 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 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, + }; + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/YaeMetadataService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/YaeMetadataService.cs new file mode 100644 index 0000000..a60ab4c --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/YaeMetadataService.cs @@ -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 GetNativeLibConfigAsync(CancellationToken token = default) + { + Task 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(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; + } + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/YaeNativeLibConfig.cs b/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/YaeNativeLibConfig.cs new file mode 100644 index 0000000..4f3eddf --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/YaeNativeLibConfig.cs @@ -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 MethodRva { get; init; } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Yae/YaeService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Yae/YaeService.cs index 42c0454..e1e7804 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Yae/YaeService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Yae/YaeService.cs @@ -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 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 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; } - - return true; } -} \ No newline at end of file +} diff --git a/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj b/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj index b6b837c..de8432a 100644 --- a/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj +++ b/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj @@ -11,7 +11,7 @@ true False - 1.18.3.0 + 1.18.4.0 False False @@ -79,6 +79,15 @@ + + + + PreserveNewest + true + unlockfps.exe + + +