GameLauncher.cs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. using System.Diagnostics;
  2. using System.Security.Cryptography;
  3. using System.Text;
  4. using System.Text.Json.Nodes;
  5. using System.Text.RegularExpressions;
  6. namespace PCL.Communist.Core;
  7. public sealed record AccountSession(string Name, string Uuid, string AccessToken, string UserType, DateTimeOffset ExpiresAt)
  8. {
  9. public static AccountSession Offline(string name)
  10. {
  11. if (!Regex.IsMatch(name, "^[A-Za-z0-9_]{3,16}$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)))
  12. throw new ArgumentException("离线名称应为 3–16 位字母、数字或下划线。");
  13. var hash = MD5.HashData(Encoding.UTF8.GetBytes("OfflinePlayer:" + name));
  14. hash[6] = (byte)((hash[6] & 0x0f) | 0x30);
  15. hash[8] = (byte)((hash[8] & 0x3f) | 0x80);
  16. return new(name, Convert.ToHexString(hash).ToLowerInvariant(), "0", "legacy", DateTimeOffset.MaxValue);
  17. }
  18. }
  19. public sealed record LaunchPlan(string Executable, string WorkingDirectory, IReadOnlyList<string> Arguments)
  20. {
  21. public ProcessStartInfo CreateStartInfo()
  22. {
  23. var info = new ProcessStartInfo(Executable) { WorkingDirectory = WorkingDirectory, UseShellExecute = false };
  24. foreach (var argument in Arguments) info.ArgumentList.Add(argument);
  25. return info;
  26. }
  27. }
  28. public sealed record RuntimeCapability(bool CanLaunch, string Description);
  29. // A future mobile backend must embed its JVM, native graphics surface and input bridge.
  30. // It must never fall through to desktop Process.Start or report a successful launch without a running game.
  31. public interface IGameRuntime
  32. {
  33. RuntimeCapability Capability { get; }
  34. Task LaunchAsync(JsonObject metadata, AccountSession account, string? java, int memoryMb,
  35. IProgress<InstallProgress>? progress, CancellationToken cancellationToken);
  36. }
  37. public sealed class GameLauncher(PlatformInfo platform, LauncherPaths paths, JavaRuntimeService javaService) : IGameRuntime
  38. {
  39. public RuntimeCapability Capability => platform.IsMobile
  40. ? new(false, "可管理版本、登录和下载游戏资源;本机游戏启动还需接入移动端 Java、图形与触控运行时。")
  41. : new(true, "原版游戏启动 · 自动选择对应系统和架构的 Java 与原生库");
  42. public async Task LaunchAsync(JsonObject metadata, AccountSession account, string? java, int memoryMb,
  43. IProgress<InstallProgress>? progress, CancellationToken cancellationToken)
  44. {
  45. if (!Capability.CanLaunch) throw new PlatformNotSupportedException(Capability.Description);
  46. var runtime = await javaService.ResolveAsync(metadata, platform, java, progress, cancellationToken);
  47. cancellationToken.ThrowIfCancellationRequested();
  48. var plan = BuildPlan(metadata, platform, paths, runtime.Executable, account, memoryMb);
  49. using var process = Process.Start(plan.CreateStartInfo()) ?? throw new IOException("未能创建游戏进程。");
  50. await Task.Delay(1500, cancellationToken);
  51. if (process.HasExited && process.ExitCode != 0)
  52. throw new IOException($"游戏进程提前退出(代码 {process.ExitCode}),请检查游戏目录内的日志。");
  53. }
  54. public static LaunchPlan BuildPlan(JsonObject metadata, PlatformInfo platform, LauncherPaths paths,
  55. string java, AccountSession account, int memoryMb)
  56. {
  57. if (platform.IsMobile) throw new PlatformNotSupportedException("移动端不能使用桌面启动命令。");
  58. if (account.ExpiresAt <= DateTimeOffset.UtcNow) throw new InvalidOperationException("登录已过期,请重新登录微软账号。");
  59. if (metadata["inheritsFrom"] is not null) throw new NotSupportedException("此移植版尚未支持加载器继承版本。");
  60. var id = metadata["id"]!.GetValue<string>();
  61. var libraries = metadata["libraries"]!.AsArray().Where(l => l is not null && MinecraftRules.Allows(l["rules"], platform)).ToArray();
  62. MinecraftRules.ValidateNativeArchitecture(metadata, platform);
  63. var classpath = libraries.Where(l => l?["downloads"]?["artifact"] is not null)
  64. .Select(l => LauncherPaths.SafeChild(paths.Libraries, l!["downloads"]!["artifact"]!["path"]!.GetValue<string>()))
  65. .Append(Path.Combine(paths.VersionDirectory(id), id + ".jar"));
  66. var assetId = metadata["assetIndex"]?["id"]?.GetValue<string>() ?? "legacy";
  67. var replacements = new Dictionary<string, string>
  68. {
  69. ["auth_player_name"] = account.Name, ["auth_uuid"] = account.Uuid,
  70. ["auth_access_token"] = account.AccessToken, ["auth_session"] = $"token:{account.AccessToken}:{account.Uuid}",
  71. ["user_type"] = account.UserType, ["user_properties"] = "{}", ["version_name"] = id,
  72. ["version_type"] = "PCL Communist", ["game_directory"] = paths.Root, ["assets_root"] = paths.Assets,
  73. ["assets_index_name"] = assetId, ["game_assets"] = LauncherPaths.SafeChild(Path.Combine(paths.Assets, "virtual"), assetId),
  74. ["natives_directory"] = Path.Combine(paths.VersionDirectory(id), "natives", platform.Kind + "-" + platform.Architecture),
  75. ["launcher_name"] = "PCL Communist", ["launcher_version"] = "0.1.0", ["classpath_separator"] = platform.ClasspathSeparator,
  76. ["library_directory"] = paths.Libraries, ["classpath"] = string.Join(platform.ClasspathSeparator, classpath),
  77. ["resolution_width"] = "1100", ["resolution_height"] = "700", ["clientid"] = MicrosoftLogin.ClientId, ["auth_xuid"] = ""
  78. };
  79. string Expand(string arg) => Regex.Replace(arg, @"\$\{([^}]+)\}", match =>
  80. replacements.TryGetValue(match.Groups[1].Value, out var value) ? value :
  81. throw new InvalidDataException($"未知的启动参数:{match.Value}"), RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
  82. var arguments = new List<string> { $"-Xmx{Math.Clamp(memoryMb, 512, 32768)}M" };
  83. if (metadata["arguments"]?["jvm"] is JsonArray jvm) arguments.AddRange(ReadArguments(jvm, platform).Select(Expand));
  84. else arguments.AddRange(["-Djava.library.path=" + replacements["natives_directory"], "-cp", replacements["classpath"]]);
  85. if (platform.Kind == PlatformKind.MacOS && !arguments.Contains("-XstartOnFirstThread")) arguments.Add("-XstartOnFirstThread");
  86. if (metadata["logging"]?["client"] is JsonNode logging)
  87. {
  88. var path = LauncherPaths.SafeChild(Path.Combine(paths.Assets, "log_configs"), logging["file"]!["id"]!.GetValue<string>());
  89. arguments.Add(logging["argument"]!.GetValue<string>().Replace("${path}", path));
  90. }
  91. arguments.Add(metadata["mainClass"]!.GetValue<string>());
  92. if (metadata["arguments"]?["game"] is JsonArray game) arguments.AddRange(ReadArguments(game, platform).Select(Expand));
  93. else
  94. {
  95. // Legacy Mojang arguments contain quoted tokens but never require a shell.
  96. foreach (Match token in Regex.Matches(metadata["minecraftArguments"]!.GetValue<string>(), "\"([^\"]*)\"|([^\\s]+)", RegexOptions.None, TimeSpan.FromSeconds(1)))
  97. arguments.Add(Expand(token.Groups[1].Success ? token.Groups[1].Value : token.Groups[2].Value));
  98. }
  99. return new(java, paths.Root, arguments);
  100. }
  101. private static IEnumerable<string> ReadArguments(JsonArray arguments, PlatformInfo platform)
  102. {
  103. foreach (var argument in arguments)
  104. {
  105. if (argument is JsonValue value) yield return value.GetValue<string>();
  106. else if (argument is JsonObject conditional && MinecraftRules.Allows(conditional["rules"], platform))
  107. {
  108. if (conditional["value"] is JsonArray values)
  109. foreach (var item in values) yield return item!.GetValue<string>();
  110. else yield return conditional["value"]!.GetValue<string>();
  111. }
  112. }
  113. }
  114. }