Files
autoupdate/Program.cs
2026-01-21 19:14:36 +08:00

1827 lines
70 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Management;
using System.Net;
using System.Net.Http;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Net.Security;
using System.Numerics;
using System.Reflection;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace SinmaiLauncher
{
public static class AntiDebug
{
private static readonly string[] BadProcessNames =
{
"dnspy",
"dnspy-x86",
"dnspy-x64",
"ilspy",
"dotpeek",
"reflector",
"x64dbg",
"x32dbg",
"ollydbg",
"idaq",
"idaq64",
"idaw",
"idaw64",
"charles",
"fiddler",
"wireshark",
"cheatengine",
};
public static void PerformChecks()
{
try
{
if (Debugger.IsAttached)
{
Environment.Exit(0xBAD);
}
if (IsAnyBadProcessRunning())
{
Environment.Exit(0xDEAD);
}
}
catch (Exception)
{
Environment.Exit(0xE);
}
}
private static bool IsAnyBadProcessRunning()
{
try
{
Process[] allProcesses = Process.GetProcesses();
foreach (var process in allProcesses)
{
try
{
string processName = process.ProcessName.ToLowerInvariant();
foreach (var badName in BadProcessNames)
{
if (processName.Contains(badName))
{
return true;
}
}
}
catch (Exception) { }
}
}
catch (Exception) { }
return false;
}
}
#endregion
public class ServerNode
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string BaseUrl { get; set; } = "";
public string Hash { get; set; } = "";
}
class Program
{
private static readonly ServerNode NodeBeijing = new ServerNode
{
Id = 1,
Name = "ZheJiang-10Gb",
BaseUrl = "",
Hash = "",
};
private static readonly ServerNode NodeTokyo = new ServerNode
{
Id = 2,
Name = "Tokyo-200Mb",
BaseUrl = "",
Hash = "",
};
private static readonly ServerNode NodeShanghai = new ServerNode
{
Id = 3,
Name = "Beijing-1Mb",
BaseUrl = "",
Hash = "",
};
private static ServerNode CurrentNode = NodeTokyo;
private enum GameMode
{
Mai,
Chuni,
Ongeki,
Unknown,
}
private static GameMode CurrentGameMode = GameMode.Unknown;
private static string ApiVersionPrefix => (CurrentNode != null && CurrentNode.Id == 1) ? "" : "";
private static string LauncherManifestUrl => $"{CurrentNode.BaseUrl}/{ApiVersionPrefix}/";
private static string ApiVerificationUrl => $"{CurrentNode.BaseUrl}/{ApiVersionPrefix}/";
private static string ApiDownloadUrl => $"{CurrentNode.BaseUrl}/{ApiVersionPrefix}/";
private static string AnnouncementUrl => $"{CurrentNode.BaseUrl}/{ApiVersionPrefix}/";
private const string LocalSecretKey =
"";
private static string ExpectedPublicKeyHash =
"";
private static readonly string ExecutablePath = Assembly.GetExecutingAssembly().Location;
private static readonly string LauncherDir = AppContext.BaseDirectory;
private static readonly string GameDir = Path.GetFullPath(Path.Combine(LauncherDir, ".."));
private static readonly string ConfigPath = Path.Combine(LauncherDir, "config.yml");
private static readonly string IcfDir = Path.Combine(GameDir, "amfs");
private static readonly HttpClient HttpClient = CreateHttpClientWithPinning();
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
};
public static void Main(string[] args)
{
AntiDebug.PerformChecks();
ServicePointManager.SecurityProtocol =
SecurityProtocolType.Tls12 | SecurityProtocolType.Tls13;
MainAsync().GetAwaiter().GetResult();
}
private static async Task SelectServerNodeAsync()
{
Console.WriteLine("正在测试节点延迟...");
Console.WriteLine();
var nodes = new List<ServerNode> { NodeBeijing, NodeTokyo, NodeShanghai };
var pingTasks = nodes.Select(async node =>
{
long latency = await PingHostAsync(node);
return (node, latency);
}).ToList();
var results = await Task.WhenAll(pingTasks);
Console.WriteLine("┌─────┬──────────────────────────────┬─────────────┐");
Console.WriteLine("│ ID │ 节点名称 │ 延迟状态 │");
Console.WriteLine("├─────┼──────────────────────────────┼─────────────┤");
foreach (var (node, latency) in results)
{
string latencyStr;
ConsoleColor color;
if (latency < 0)
{
latencyStr = "超时";
color = ConsoleColor.Red;
}
else if (latency < 100)
{
latencyStr = $"{latency} ms";
color = ConsoleColor.Green;
}
else if (latency < 200)
{
latencyStr = $"{latency} ms";
color = ConsoleColor.Yellow;
}
else
{
latencyStr = $"{latency} ms";
color = ConsoleColor.Red;
}
int nameWidth = GetVisualWidth(node.Name);
int padding = 28 - nameWidth;
string paddedName = node.Name + new string(' ', Math.Max(0, padding));
Console.Write("│ ");
Console.Write($"{node.Id,-3}");
Console.Write(" │ ");
Console.Write(paddedName);
Console.Write(" │ ");
Console.ForegroundColor = color;
Console.Write($"{latencyStr,-11}");
Console.ResetColor();
Console.WriteLine(" │");
}
Console.WriteLine("└─────┴──────────────────────────────┴─────────────┘");
Console.WriteLine(" F1 | 国服绿网");
Console.WriteLine("\n按数字选择节点默认 2 = Tokyo");
ConsoleKeyInfo key = Console.ReadKey(true);
if (key.Key == ConsoleKey.F1)
{
await RunGreenNetTool();
PauseAndExit();
return;
}
ServerNode selected = key.Key switch
{
ConsoleKey.D1 => NodeBeijing,
ConsoleKey.NumPad1 => NodeBeijing,
ConsoleKey.D2 => NodeTokyo,
ConsoleKey.NumPad2 => NodeTokyo,
ConsoleKey.D3 => NodeShanghai,
ConsoleKey.NumPad3 => NodeShanghai,
_ => NodeTokyo,
};
if (selected.Id == 1)
{
selected.BaseUrl = NodeTokyo.BaseUrl;
selected.Hash = NodeTokyo.Hash;
}
CurrentNode = selected;
ExpectedPublicKeyHash = selected.Hash;
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"\n已选择节点{selected.Name}");
Console.ResetColor();
await Task.Delay(800);
Console.Clear();
}
private static int GetVisualWidth(string s)
{
int len = 0;
foreach (var c in s)
{
len += (c < 128) ? 1 : 2;
}
return len;
}
private static async Task<long> PingHostAsync(ServerNode node)
{
try
{
var uri = new Uri(node.BaseUrl);
string host = uri.Host;
if (node.Id == 1)
{
try
{
var sw = Stopwatch.StartNew();
using (var client = new TcpClient())
{
var connectTask = client.ConnectAsync(host, 443);
var timeoutTask = Task.Delay(5000);
if (await Task.WhenAny(connectTask, timeoutTask) == connectTask)
{
await connectTask;
if (client.Connected)
{
sw.Stop();
return sw.ElapsedMilliseconds;
}
}
}
}
catch
{
return -1;
}
return -1;
}
using (var ping = new Ping())
{
PingReply reply = await ping.SendPingAsync(host, 5000);
if (reply.Status == IPStatus.Success)
{
return reply.RoundtripTime;
}
}
}
catch { }
return -1;
}
static async Task MainAsync()
{
Console.ForegroundColor = ConsoleColor.White;
Console.Clear();
string currentVersion =
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "N/A";
DetectGameMode();
await SelectServerNodeAsync();
if (CurrentGameMode == GameMode.Unknown)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("baka~baka~");
Console.WriteLine("工具放错位置了哦");
Console.ResetColor();
PauseAndExit();
return;
}
string gameModeName;
switch (CurrentGameMode)
{
case GameMode.Mai:
gameModeName = "maimai";
break;
case GameMode.Chuni:
gameModeName = "CHUNITHM";
break;
case GameMode.Ongeki:
gameModeName = "ONGEKI Re:Fresh";
break;
default:
gameModeName = "未知";
break;
}
Console.WriteLine($"当前运行游戏: {gameModeName}");
string gameId = GetGameIdString(CurrentGameMode);
if (gameId == "SDEZ")
{
Console.WriteLine("当前运行游戏SDEZ喵");
}
else if (gameId == "SDGB")
{
Console.WriteLine("当前运行游戏SDGB喵");
}
Console.Title = $"启动器 v{currentVersion} - [{gameModeName}] - [{gameId}]";
if (await CheckAndApplySelfUpdate())
{
return;
}
await ShowAnnouncement();
try
{
Config cfg = LoadOrCreateConfig();
Console.WriteLine("正在验证机器码ing...");
GamePermissions? permissions = await VerifyApiAsync(cfg.Mc);
if (permissions == null)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("啊嘞?机器码或通信验证失败惹");
Console.WriteLine("请联系群主授权喵");
Console.ResetColor();
PauseAndExit();
return;
}
Console.WriteLine("验证成功");
bool hasPermission = false;
switch (gameId)
{
case "SDGB":
hasPermission = permissions.SDGB;
break;
case "SDEZ":
hasPermission = permissions.SDEZ;
break;
case "SDHD":
hasPermission = permissions.SDHD;
break;
case "SDDT":
hasPermission = permissions.SDDT;
break;
}
if (!hasPermission)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine(
$"啊哦,您的账户没有 [{gameId}] ({gameModeName}) 的游戏权限喵"
);
Console.WriteLine("请联系群主开通哦");
Console.ResetColor();
PauseAndExit();
return;
}
await CheckAndApplyGameUpdates(cfg.Mc, gameId);
Console.WriteLine("\n正在启动游戏...");
StartGame(cfg);
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("\n启动器运行期间发生严重错误");
LogExceptionDetails(ex);
Console.ResetColor();
PauseAndExit();
}
}
private static async Task RunGreenNetTool()
{
Console.Clear();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("=== 国服绿网工具 ===");
Console.ResetColor();
Config cfg = LoadOrCreateConfig();
Console.WriteLine("正在验证权限...");
try
{
GamePermissions? permissions = await VerifyApiAsync(cfg.Mc);
if (permissions == null || !permissions.LV)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("权限验证失败喵");
Console.WriteLine("5r喵~需要请联系群主喵");
Console.ResetColor();
Console.WriteLine("\n按任意键返回...");
Console.ReadKey(true);
return;
}
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"验证过程出错: {ex.Message}");
Console.ResetColor();
Console.WriteLine("\n按任意键返回...");
Console.ReadKey(true);
return;
}
Console.WriteLine("权限验证通过,开始修改 hosts...");
try
{
await ModifyHostsFile();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("\n操作成功完成");
Console.WriteLine("请重启游戏或电脑以确保生效。");
Console.ResetColor();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n操作失败: {ex.Message}");
if (ex is UnauthorizedAccessException)
{
Console.WriteLine("请尝试以管理员身份运行此程序。");
}
Console.ResetColor();
}
Console.WriteLine("\n按任意键退出...");
Console.ReadKey(true);
}
private static async Task ModifyHostsFile()
{
Config cfg = LoadOrCreateConfig();
string hostsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), @"");
if (!File.Exists(hostsPath))
{
throw new FileNotFoundException("未找到系统 hosts 文件", hostsPath);
}
var lines = File.ReadAllLines(hostsPath).ToList();
var domainsToRemove = new HashSet<string> { "" };
int removedCount = lines.RemoveAll(line =>
{
if (string.IsNullOrWhiteSpace(line)) return false;
if (line.TrimStart().StartsWith("#")) return false;
foreach (var domain in domainsToRemove)
{
if (line.Contains(domain)) return true;
}
return false;
});
Console.WriteLine($"已清理 {removedCount} 条旧记录");
Console.WriteLine($"正在从服务器获取最新数据...");
string localR2f = GenerateLocalR2FCode(cfg.Mc);
var hostsRequestPayload = new
{
r2f_code = localR2f,
request_hosts = true,
mc = cfg.Mc
};
string jsonPayload = JsonSerializer.Serialize(hostsRequestPayload);
string encryptedPayload = AuthCipher.Encrypt(jsonPayload, cfg.Mc);
var requestBody = new
{
mc = cfg.Mc,
payload = encryptedPayload
};
var content = new StringContent(
JsonSerializer.Serialize(requestBody),
Encoding.UTF8,
"application/json"
);
string newData = "";
try
{
HttpResponseMessage response = await HttpClient.PostAsync(ApiDownloadUrl, content);
response.EnsureSuccessStatusCode();
string encryptedResponse = await response.Content.ReadAsStringAsync();
string decryptedJson = AuthCipher.Decrypt(encryptedResponse, cfg.Mc);
if (decryptedJson == null)
{
try
{
using (JsonDocument doc = JsonDocument.Parse(encryptedResponse))
{
if (doc.RootElement.TryGetProperty("message", out JsonElement msg))
{
throw new Exception($"服务器返回错误: {msg.GetString()}");
}
}
}
catch (JsonException) { }
throw new Exception("解密服务器响应失败");
}
HostsResponse? hostsResp = JsonSerializer.Deserialize<HostsResponse>(decryptedJson, JsonOptions);
if (hostsResp == null || !hostsResp.Success)
{
throw new Exception(hostsResp?.Message ?? "未知错误");
}
newData = hostsResp.HostsContent ?? "";
}
catch (Exception ex)
{
throw new Exception($"获取 hosts 数据失败: {ex.Message}");
}
var newLines = newData.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
int addedCount = 0;
foreach (var line in newLines)
{
var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
string p1 = parts[0];
string p2 = parts[1];
string ip = "";
string domain = "";
if (IPAddress.TryParse(p2, out _))
{
domain = p1;
ip = p2;
}
else if (IPAddress.TryParse(p1, out _))
{
ip = p1;
domain = p2;
}
if (!string.IsNullOrEmpty(ip) && !string.IsNullOrEmpty(domain))
{
lines.Add($"{ip} {domain}");
addedCount++;
}
}
}
if (addedCount == 0)
{
Console.WriteLine("警告: 未能从服务器数据中解析出有效的 hosts 记录");
}
File.WriteAllLines(hostsPath, lines);
Console.WriteLine("hosts 文件写入完成");
try
{
Process.Start(new ProcessStartInfo
{
FileName = "ipconfig",
Arguments = "/flushdns",
CreateNoWindow = true,
UseShellExecute = false
})?.WaitForExit();
Console.WriteLine("DNS 缓存已刷新");
}
catch { }
}
private static async Task ShowAnnouncement()
{
string lastGgPath = Path.Combine(LauncherDir, "");
try
{
Console.WriteLine("\n正在获取公告信息...");
string announcement = await HttpClient.GetStringAsync(AnnouncementUrl);
announcement = announcement.Trim();
if (string.IsNullOrWhiteSpace(announcement))
return;
string last = "";
if (File.Exists(lastGgPath))
last = File.ReadAllText(lastGgPath).Trim();
if (last == announcement)
return;
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("\n" + new string('-', 70));
Console.WriteLine("公告");
Console.WriteLine(new string('-', 70));
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(announcement);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine(new string('-', 70));
Console.ResetColor();
Console.WriteLine("\n按任意键继续...");
Console.ReadKey();
Console.Clear();
File.WriteAllText(lastGgPath, announcement);
}
catch
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine("无法获取公告信息,跳过显示");
Console.ResetColor();
}
}
private static HttpClient CreateHttpClientWithPinning()
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (request, cert, chain, errors) =>
{
if (CurrentNode != null && CurrentNode.Id == 3)
{
return true;
}
if (CurrentNode != null && request != null)
{
try
{
string requestHost = request.RequestUri.Host;
string apiHost = new Uri(CurrentNode.BaseUrl).Host;
if (!string.Equals(requestHost, apiHost, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
catch { }
}
if (errors != SslPolicyErrors.None || cert == null)
{
Console.WriteLine($"SSL错误: {errors}");
return false;
}
try
{
using var sha256 = SHA256.Create();
var publicKeyHash = sha256.ComputeHash(cert.GetPublicKey());
var hashString = BitConverter
.ToString(publicKeyHash)
.Replace("-", "")
.ToLowerInvariant();
if (hashString == ExpectedPublicKeyHash)
{
return true;
}
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n[SSL 验证失败] 证书 Hash 不匹配!");
Console.ResetColor();
return false;
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[SSL 验证异常]: {ex.Message}");
Console.ResetColor();
return false;
}
},
};
var client = new HttpClient(handler);
client.DefaultRequestHeaders.UserAgent.ParseAdd("xiaoxin/baka");
return client;
}
private static async Task<bool> CheckAndApplySelfUpdate()
{
Console.WriteLine("正在检查启动器更新...");
try
{
string json = await HttpClient.GetStringAsync(LauncherManifestUrl);
var manifest = JsonSerializer.Deserialize<UpdateManifest>(json, JsonOptions);
if (
manifest?.Launcher == null
|| string.IsNullOrEmpty(manifest.Launcher.Name)
|| string.IsNullOrEmpty(manifest.Launcher.Hash)
|| string.IsNullOrEmpty(manifest.Launcher.Url)
)
{
Console.WriteLine("启动器清单文件中缺少信息");
return false;
}
string fileToCheck = manifest.Launcher.Name;
string serverHash = manifest.Launcher.Hash;
string localFilePath = Path.Combine(LauncherDir, fileToCheck);
Console.WriteLine($" -> 正在校验本地核心文件: {localFilePath}");
bool needsUpdate = false;
if (!File.Exists(localFilePath))
{
needsUpdate = true;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(" -> 状态: 核心文件不存在,需要更新");
Console.ResetColor();
}
else if (!VerifyFileHash(localFilePath, serverHash))
{
needsUpdate = true;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(" -> 状态: 有新版本啦~");
Console.ResetColor();
}
if (needsUpdate)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("正在准备下载ing...");
Console.ResetColor();
await ApplySelfUpdate(manifest.Launcher);
return true;
}
else
{
Console.WriteLine("已经是最新版了哦");
return false;
}
}
catch (HttpRequestException ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("无法连接到更新服务器,请检查网络配置");
LogExceptionDetails(ex);
Console.ResetColor();
PauseAndExit();
return false;
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"呜哇!检查更新时发生错误惹:");
LogExceptionDetails(ex);
Console.ResetColor();
return false;
}
}
private static async Task ApplySelfUpdate(LauncherInfo newLauncher)
{
string batPath = Path.Combine(LauncherDir, "updater.bat");
string downloadPath = Path.Combine(LauncherDir, "update_new.dll");
try
{
await DownloadFileAsync(
newLauncher.Url,
downloadPath,
"新版核心 update.dll",
newLauncher.Hash
);
string batchContent =
@"";
File.WriteAllText(batPath, batchContent, Encoding.Default);
Process.Start(
new ProcessStartInfo(batPath) { CreateNoWindow = true, UseShellExecute = false }
);
Environment.Exit(0);
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("自动更新失败");
LogExceptionDetails(ex);
Console.ResetColor();
PauseAndExit();
}
}
private static async Task CheckAndApplyGameUpdates(string mc, string gameId)
{
if (string.IsNullOrEmpty(gameId) || gameId == "unknown")
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("内部错误无法识别的游戏ID无法更新。");
Console.ResetColor();
return;
}
try
{
string localR2f = GenerateLocalR2FCode(mc);
var treeRequestPayload = new { r2f_code = localR2f, request_tree = gameId };
string decryptedTreeJson = await PostToDownloadApiAsync(mc, treeRequestPayload);
if (string.IsNullOrEmpty(decryptedTreeJson))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("获取文件列表失败");
Console.ResetColor();
return;
}
var treeResponse = JsonSerializer.Deserialize<FileNameListResponse>(
decryptedTreeJson,
JsonOptions
);
if (treeResponse == null || !treeResponse.Success || treeResponse.FileTree == null)
{
Console.ForegroundColor = ConsoleColor.Red;
if (
treeResponse?.Message != null
&& treeResponse.Message.Contains("permission denied")
)
{
Console.WriteLine("获取文件列表失败: 游戏权限被服务器拒绝。");
}
else
{
Console.WriteLine(
"获取文件列表失败: " + (treeResponse?.Message ?? "未知错误")
);
}
Console.ResetColor();
return;
}
Console.WriteLine(
$"成功获取到 {treeResponse.FileTree.Count} 个文件信息,正在比对本地文件..."
);
var filesToRequestBag = new ConcurrentBag<string>();
int totalFiles = treeResponse.FileTree.Count;
int processedFiles = 0;
try { Console.CursorVisible = false; } catch { }
Parallel.ForEach(treeResponse.FileTree, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount * 2 }, entry =>
{
string serverFileName = entry.Key;
FileNodeInfo? nodeInfo = entry.Value;
string? serverHash = nodeInfo?.Hash;
string? category = nodeInfo?.Category;
if (!string.IsNullOrEmpty(serverFileName))
{
string localPath = GetLocalPathForFile(serverFileName, category);
bool needsUpdate = false;
if (!File.Exists(localPath))
{
needsUpdate = true;
}
else if (!string.IsNullOrEmpty(serverHash))
{
string localHash = GetFileHash(localPath);
if (!localHash.Equals(serverHash, StringComparison.OrdinalIgnoreCase))
{
needsUpdate = true;
}
}
if (needsUpdate)
{
filesToRequestBag.Add(serverFileName);
}
}
int current = Interlocked.Increment(ref processedFiles);
if (current % 5 == 0 || current == totalFiles)
{
Console.Write($"\r -> 校验进度: {current}/{totalFiles} ({(int)((float)current / totalFiles * 100)}%)");
}
});
try { Console.CursorVisible = true; } catch { }
Console.WriteLine();
var filesToRequest = filesToRequestBag.ToList();
if (filesToRequest.Count == 0)
{
Console.WriteLine("\n所有必要的游戏文件都存在哦");
return;
}
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(
$"\n发现 {filesToRequest.Count} 个本地缺失或需要更新的文件,正在请求下载链接..."
);
Console.ResetColor();
localR2f = GenerateLocalR2FCode(mc);
var linksRequestPayload = new
{
r2f_code = localR2f,
request_files = new { game = gameId, files = filesToRequest },
};
string decryptedLinksJson = await PostToDownloadApiAsync(mc, linksRequestPayload);
if (string.IsNullOrEmpty(decryptedLinksJson))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("获取下载链接失败");
Console.ResetColor();
return;
}
var linksResponse = JsonSerializer.Deserialize<FileLinksResponse>(
decryptedLinksJson,
JsonOptions
);
if (
linksResponse == null
|| !linksResponse.Success
|| linksResponse.FileLinks == null
)
{
Console.ForegroundColor = ConsoleColor.Red;
if (
linksResponse?.Message != null
&& linksResponse.Message.Contains("permission denied")
)
{
Console.WriteLine("获取下载链接失败: 游戏权限被服务器拒绝。");
}
else
{
Console.WriteLine(
"获取下载链接失败: " + (linksResponse?.Message ?? "未知错误")
);
}
Console.ResetColor();
return;
}
long totalSize = 0;
long totalDownloaded = 0;
Console.WriteLine("正在计算总下载大小...");
var sizeTasks = linksResponse.FileLinks.Select(async kvp =>
{
if(kvp.Value?.Url != null)
{
long size = await GetFileSizeAsync(kvp.Value.Url);
Interlocked.Add(ref totalSize, size);
}
});
await Task.WhenAll(sizeTasks);
Console.WriteLine($"总计大小: {FormatBytes(totalSize)},开始下载...");
try { Console.CursorVisible = false; } catch { }
var stopwatch = Stopwatch.StartNew();
int updatedCount = 0;
foreach (var entry in linksResponse.FileLinks)
{
string fileName = entry.Key;
FileLinkInfo? linkInfo = entry.Value;
string? category = null;
if (treeResponse.FileTree.TryGetValue(fileName, out var originalNode))
{
category = originalNode.Category;
}
string localPath = GetLocalPathForFile(fileName, category);
if (linkInfo == null || string.IsNullOrEmpty(linkInfo.Url))
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($" -> 服务器未返回文件 {fileName} 的有效 URL跳过");
Console.ResetColor();
continue;
}
string? expectedHash = linkInfo.Hash;
if (string.IsNullOrEmpty(expectedHash) && originalNode != null)
{
expectedHash = originalNode.Hash;
}
try
{
await DownloadFileAsync(linkInfo.Url, localPath, fileName, expectedHash, (readBytes) =>
{
long newTotal = Interlocked.Add(ref totalDownloaded, readBytes);
DrawGlobalProgressBar(newTotal, totalSize, stopwatch.Elapsed);
});
updatedCount++;
}
catch (Exception ex)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($" -> 下载 {fileName} 失败: {ex.Message}");
Console.ResetColor();
}
}
try { Console.CursorVisible = true; } catch { }
Console.WriteLine();
Console.WriteLine(
$"\n游戏文件更新完成总计下载了 {updatedCount} 个缺失文件呢,哼哼ᗜˬᗜ"
);
}
catch (JsonException jsonEx)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"解析服务器响应时出错 (可能是通信密钥不同步): {jsonEx.Message}");
Console.ResetColor();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("检查游戏更新时发生严重错误QAQ");
LogExceptionDetails(ex);
Console.ResetColor();
}
}
private static string GetLocalPathForFile(string name, string? category)
{
string relativePath = name.Replace('/', Path.DirectorySeparatorChar);
if (string.IsNullOrEmpty(category))
{
return Path.Combine(GameDir, relativePath);
}
switch (category.ToLowerInvariant())
{
case "option":
return Path.Combine(GameDir, "option", relativePath);
case "file":
return Path.Combine(GameDir, relativePath);
case "amfs":
return Path.Combine(GameDir, "amfs", relativePath);
case "dll":
return Path.Combine(GameDir, "Sinmai_Data", "Managed", relativePath);
case "opt":
return Path.Combine(GameDir, "Sinmai_Data", "StreamingAssets", relativePath);
default:
return Path.Combine(GameDir, relativePath);
}
}
private static async Task<string?> PostToDownloadApiAsync(string mc, object payloadObject)
{
try
{
string payloadJson = JsonSerializer.Serialize(payloadObject);
string encryptedPayload = AuthCipher.Encrypt(payloadJson, mc);
var apiRequest = new ApiAuthRequest { Mc = mc, Payload = encryptedPayload };
string jsonRequest = JsonSerializer.Serialize(apiRequest);
var httpContent = new StringContent(jsonRequest, Encoding.UTF8, "application/json");
var response = await HttpClient.PostAsync(ApiDownloadUrl, httpContent);
response.EnsureSuccessStatusCode();
string encryptedResponse = await response.Content.ReadAsStringAsync();
string decryptedJson = AuthCipher.Decrypt(encryptedResponse, mc);
return decryptedJson;
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[Download API] 请求失败: {ex.Message}");
Console.ResetColor();
return null;
}
}
private static async Task DownloadFileAsync(
string? url,
string localPath,
string? displayName,
string? expectedHash,
Action<int>? progressCallback = null
)
{
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(displayName))
{
return;
}
bool quiet = progressCallback != null;
if (!quiet) Console.WriteLine($" -> 准备下载: {displayName}");
string? directory = Path.GetDirectoryName(localPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
try
{
using var response = await HttpClient.GetAsync(
url,
HttpCompletionOption.ResponseHeadersRead
);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync();
Stopwatch? sw = quiet ? null : Stopwatch.StartNew();
using (
var fileStream = new FileStream(
localPath,
FileMode.Create,
FileAccess.Write,
FileShare.None
)
)
{
byte[] buffer = new byte[8192];
long totalBytes = response.Content.Headers.ContentLength ?? -1;
long totalRead = 0;
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await fileStream.WriteAsync(buffer, 0, bytesRead);
totalRead += bytesRead;
if (quiet)
{
progressCallback?.Invoke(bytesRead);
}
else if (totalBytes > 0)
{
var percentage = (int)((double)totalRead * 100 / totalBytes);
Console.Write(
$"\r -> 正在下载... {percentage}% ({totalRead / 1024 / 1024}MB / {totalBytes / 1024 / 1024}MB)"
);
}
}
}
sw?.Stop();
if (!quiet)
{
Console.WriteLine(
$"\r -> 下载完成: {displayName} ({sw?.Elapsed.TotalSeconds:F2}s) "
);
}
if (!string.IsNullOrEmpty(expectedHash))
{
if (!VerifyFileHash(localPath, expectedHash, false))
{
throw new Exception("文件校验失败可能已损坏惹QAQ");
}
if (!quiet) Console.WriteLine(" -> 文件校验成功");
}
else
{}
}
catch (Exception ex)
{
if (!quiet)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n -> 下载 {displayName} 时发生错误QAQ: {ex.Message}");
Console.ResetColor();
}
if (File.Exists(localPath))
{
try
{
File.Delete(localPath);
}
catch { }
}
throw;
}
}
private static async Task<long> GetFileSizeAsync(string url)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Head, url);
using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (response.IsSuccessStatusCode)
{
return response.Content.Headers.ContentLength ?? 0;
}
}
catch { }
return 0;
}
private static void DrawGlobalProgressBar(long currentBytes, long totalBytes, TimeSpan elapsed)
{
if (totalBytes <= 0) return;
double progress = (double)currentBytes / totalBytes;
progress = Math.Min(1.0, Math.Max(0.0, progress));
int barWidth = 30;
int filledChars = (int)(progress * barWidth);
double seconds = elapsed.TotalSeconds;
string speedStr = "0 B/s";
if (seconds > 0)
{
double speed = currentBytes / seconds;
speedStr = $"{FormatBytes((long)speed)}/s";
}
string bar = new string('=', filledChars) + new string(' ', barWidth - filledChars);
if (filledChars > 0 && filledChars < barWidth)
{
bar = bar.Remove(filledChars - 1, 1).Insert(filledChars - 1, ">");
}
else if (filledChars == barWidth)
{
bar = new string('=', barWidth);
}
string arrowBar = new string('=', filledChars);
if (filledChars < barWidth) arrowBar += ">";
string barDisplay = arrowBar.PadRight(barWidth + 1);
Console.Write($"\r{barDisplay} {FormatBytes(currentBytes)}/{FormatBytes(totalBytes)} {speedStr} ");
}
private static string FormatBytes(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
private static bool VerifyFileHash(
string filePath,
string? expectedHash,
bool quiet = false
)
{
if (string.IsNullOrEmpty(expectedHash))
{
return true;
}
if (expectedHash.StartsWith("在此填入"))
{
if (!quiet)
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" -> 跳过校验: {Path.GetFileName(filePath)}");
Console.ResetColor();
}
return true;
}
string actualHash = GetFileHash(filePath);
bool isMatch = actualHash.Equals(expectedHash, StringComparison.OrdinalIgnoreCase);
if (!isMatch && !quiet)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" -> 文件不匹配!");
Console.WriteLine($" 本地: {actualHash}");
Console.ResetColor();
}
return isMatch;
}
private static Config LoadOrCreateConfig()
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.Build();
var serializer = new SerializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.Build();
Config cfg;
string newMc = GenerateMachineCode();
if (File.Exists(ConfigPath))
{
string yaml = File.ReadAllText(ConfigPath);
cfg = deserializer.Deserialize<Config>(yaml);
bool configUpdated = false;
if (cfg.Mc != newMc)
{
cfg.Mc = newMc;
configUpdated = true;
}
if (cfg.MaiArgs == null)
{
cfg.MaiArgs =
"";
configUpdated = true;
}
if (configUpdated)
{
string newYaml = serializer.Serialize(cfg);
File.WriteAllText(ConfigPath, newYaml);
}
}
else
{
cfg = new Config
{
MaiArgs =
"",
Mc = newMc,
};
string yaml = serializer.Serialize(cfg);
File.WriteAllText(ConfigPath, yaml);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("首次运行:已在启动器目录创建 config.yml");
Console.WriteLine("请将您的机器码提供给猫猫进行授权");
Console.WriteLine("价格请看Q群公告");
Console.WriteLine("机器码:");
Console.ResetColor();
Console.WriteLine(cfg.Mc);
PauseAndExit();
}
return cfg;
}
private static string GenerateMachineCode()
{
try
{
string boardSerial = "";
string cpuId = "";
using (
var searcher = new ManagementObjectSearcher(
"SELECT SerialNumber FROM Win32_BaseBoard"
)
)
{
foreach (var obj in searcher.Get())
{
boardSerial = obj["SerialNumber"]?.ToString() ?? "";
break;
}
}
using (
var searcher = new ManagementObjectSearcher(
"SELECT ProcessorId FROM Win32_Processor"
)
)
{
foreach (var obj in searcher.Get())
{
cpuId = obj["ProcessorId"]?.ToString() ?? "";
break;
}
}
string combined = boardSerial + cpuId;
using var sha256 = SHA256.Create();
byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(combined));
string finalMc = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
return finalMc;
}
catch (Exception)
{
return "wmi_error_" + Guid.NewGuid().ToString("N");
}
}
static async Task<GamePermissions?> VerifyApiAsync(string mc)
{
try
{
string url = ApiVerificationUrl;
string staticPayload = "";
string encryptedPayload = AuthCipher.Encrypt(staticPayload, mc);
var requestData = new ApiAuthRequest { Mc = mc, Payload = encryptedPayload };
string jsonRequest = JsonSerializer.Serialize(requestData);
var httpContent = new StringContent(jsonRequest, Encoding.UTF8, "application/json");
var response = await HttpClient.PostAsync(url, httpContent);
response.EnsureSuccessStatusCode();
string encryptedResponse = await response.Content.ReadAsStringAsync();
string decryptedJson = AuthCipher.Decrypt(encryptedResponse, mc);
var jsonDoc = JsonDocument.Parse(
decryptedJson,
new JsonDocumentOptions { AllowTrailingCommas = true }
);
var root = jsonDoc.RootElement;
if (
!root.TryGetProperty("success", out var successProp)
|| !successProp.GetBoolean()
)
{
return null;
}
if (
!root.TryGetProperty("r2f_code", out var r2fCodeProp)
|| r2fCodeProp.ValueKind != JsonValueKind.String
)
{
return null;
}
string serverR2fCode = r2fCodeProp.GetString() ?? "";
bool isR2fValid = false;
for (int offset = -1; offset <= 1; offset++)
{
string localR2fCode = GenerateLocalR2FCode(mc, offset);
if (serverR2fCode == localR2fCode)
{
isR2fValid = true;
break;
}
}
if (!isR2fValid)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("错误: 认证码校验失败 (可能是本地时间与服务器差异过大)");
Console.ResetColor();
return null;
}
if (!root.TryGetProperty("permissions", out var permsProp))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("警告: 验证服务器未返回权限信息");
Console.ResetColor();
return null;
}
var permissions = JsonSerializer.Deserialize<GamePermissions>(
permsProp.GetRawText(),
JsonOptions
);
if (permissions == null)
{
return null;
}
return permissions;
}
catch (HttpRequestException)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("API 验证请求失败喵,请检查网络配置");
Console.ResetColor();
return null;
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"API 验证期间发生错误喵");
Console.ResetColor();
return null;
}
}
private static string GenerateLocalR2FCode(string machineCode, int timeStepOffset = 0)
{
var key = Encoding.UTF8.GetBytes(LocalSecretKey);
using var hmac = new HMACSHA1(key);
long timeStep = (DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30) + timeStepOffset;
var timeStepBytes = BitConverter.GetBytes(timeStep);
if (BitConverter.IsLittleEndian)
{
Array.Reverse(timeStepBytes);
}
var machineCodeBytes = Encoding.UTF8.GetBytes(machineCode);
var combinedBytes = new byte[timeStepBytes.Length + machineCodeBytes.Length];
Buffer.BlockCopy(timeStepBytes, 0, combinedBytes, 0, timeStepBytes.Length);
Buffer.BlockCopy(
machineCodeBytes,
0,
combinedBytes,
timeStepBytes.Length,
machineCodeBytes.Length
);
var hash = hmac.ComputeHash(combinedBytes);
int offset = hash[19] & 0x0A;
var chunk = new byte[10];
Buffer.BlockCopy(hash, offset, chunk, 0, 10);
Array.Reverse(chunk);
var positiveChunk = new byte[chunk.Length + 1];
Buffer.BlockCopy(chunk, 0, positiveChunk, 0, chunk.Length);
positiveChunk[chunk.Length] = 0x00;
var binary = new BigInteger(positiveChunk);
var modulo = BigInteger.Pow(10, 20);
var otp = BigInteger.Remainder(binary, modulo);
if (otp.Sign < 0)
{
otp += modulo;
}
string finalOtp = otp.ToString();
return finalOtp;
}
static void StartGame(Config cfg)
{
try
{
string batPath = Path.Combine(GameDir, "start_temp.bat");
string batchContent;
if (CurrentGameMode == GameMode.Chuni)
{
batchContent =
$@"";
}
else if (CurrentGameMode == GameMode.Ongeki)
{
batchContent =
$@"";
}
else
{
batchContent =
$@"";
}
File.WriteAllText(batPath, batchContent, Encoding.Default);
var psi = new ProcessStartInfo(batPath)
{
WorkingDirectory = GameDir,
UseShellExecute = true,
};
Process.Start(psi);
Environment.Exit(0);
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("启动游戏时发生错误");
LogExceptionDetails(ex);
Console.ResetColor();
}
}
private static void DetectGameMode()
{
if (File.Exists(Path.Combine(GameDir, "chusanhook.dll")))
{
CurrentGameMode = GameMode.Chuni;
}
else if (File.Exists(Path.Combine(GameDir, "mai2hook.dll")))
{
CurrentGameMode = GameMode.Mai;
}
else if (File.Exists(Path.Combine(GameDir, "mu3hook.dll")))
{
CurrentGameMode = GameMode.Ongeki;
}
else
{
CurrentGameMode = GameMode.Unknown;
}
}
private static string GetGameIdString(GameMode mode)
{
switch (mode)
{
case GameMode.Chuni:
return "SDHD";
case GameMode.Ongeki:
return "SDDT";
case GameMode.Mai:
string caPemPath = Path.Combine(GameDir, "ca.pem");
if (File.Exists(caPemPath))
{
return "SDEZ";
}
else
{
return "SDGB";
}
default:
return "unknown";
}
}
private static string GetFileHash(string filePath)
{
if (!File.Exists(filePath))
{
return "文件不存在";
}
try
{
using var sha256 = SHA256.Create();
using var stream = File.OpenRead(filePath);
byte[] hash = sha256.ComputeHash(stream);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
catch (Exception ex)
{
return $"无法计算哈希: {ex.Message}";
}
}
private static void PauseAndExit()
{
Console.WriteLine("\n按任意键退出...");
Console.ReadKey();
Environment.Exit(1);
}
private static void LogExceptionDetails(Exception ex, string indent = "")
{
if (ex == null)
return;
string errorType = $"{indent}类型: {ex.GetType().FullName}";
string errorMessage = $"{indent}信息: {ex.Message}";
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($"{indent}--- 详细错误日志 ---");
Console.WriteLine(errorType);
Console.WriteLine(errorMessage);
Console.ResetColor();
if (ex.InnerException != null)
{
LogExceptionDetails(ex.InnerException, indent + " ");
}
}
public static class AuthCipher
{
private const int AesKeySize = 32;
private const int AesIvSize = 16;
private static byte[] GenerateSessionToken(string mc)
{
try
{
string combinedString = LocalSecretKey + mc;
using (SHA256 sha256 = SHA256.Create())
{
byte[] combinedBytes = Encoding.UTF8.GetBytes(combinedString);
return sha256.ComputeHash(combinedBytes);
}
}
catch (Exception)
{
return new byte[AesKeySize];
}
}
public static string Encrypt(string plainText, string mc)
{
byte[] key = GenerateSessionToken(mc);
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
using (Aes aes = Aes.Create())
{
aes.Key = key;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.GenerateIV();
byte[] iv = aes.IV;
using (ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, iv))
using (MemoryStream ms = new MemoryStream())
{
ms.Write(iv, 0, iv.Length);
using (
CryptoStream cs = new CryptoStream(
ms,
encryptor,
CryptoStreamMode.Write
)
)
{
cs.Write(plainBytes, 0, plainBytes.Length);
cs.FlushFinalBlock();
}
return Convert.ToBase64String(ms.ToArray());
}
}
}
public static string Decrypt(string cipherTextBase64, string mc)
{
byte[] key = GenerateSessionToken(mc);
byte[] combinedBytes = Convert.FromBase64String(cipherTextBase64);
if (combinedBytes.Length < AesIvSize)
{
throw new CryptographicException("无效");
}
using (Aes aes = Aes.Create())
{
aes.Key = key;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
byte[] iv = new byte[AesIvSize];
Buffer.BlockCopy(combinedBytes, 0, iv, 0, iv.Length);
aes.IV = iv;
int cipherBytesLength = combinedBytes.Length - AesIvSize;
byte[] cipherBytes = new byte[cipherBytesLength];
Buffer.BlockCopy(combinedBytes, AesIvSize, cipherBytes, 0, cipherBytesLength);
using (ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV))
using (MemoryStream msDecrypt = new MemoryStream(cipherBytes))
using (
CryptoStream csDecrypt = new CryptoStream(
msDecrypt,
decryptor,
CryptoStreamMode.Read
)
)
using (StreamReader srDecrypt = new StreamReader(csDecrypt))
{
return srDecrypt.ReadToEnd();
}
}
}
}
}
public class Config
{
[YamlMember(Alias = "sinmaiargs")]
public string MaiArgs { get; set; } = "";
[YamlMember(Alias = "mc")]
public string Mc { get; set; } = "";
public int server_line { get; set; } = 2;
}
public class UpdateManifest
{
[JsonPropertyName("dll")]
public List<UpdateFile>? Dll { get; set; }
[JsonPropertyName("Launcher")]
public LauncherInfo? Launcher { get; set; }
[JsonPropertyName("opt")]
public List<UpdateFile>? Opt { get; set; }
[JsonPropertyName("option")]
public List<UpdateFile>? Option { get; set; }
[JsonPropertyName("file")]
public List<UpdateFile>? File { get; set; }
[JsonPropertyName("amfs")]
public JsonElement Amfs { get; set; }
}
public class LauncherInfo
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("url")]
public string? Url { get; set; }
[JsonPropertyName("hash")]
public string? Hash { get; set; }
}
public class UpdateFile
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("url")]
public string? Url { get; set; }
[JsonPropertyName("hash")]
public string? Hash { get; set; }
}
public class ApiAuthRequest
{
[JsonPropertyName("mc")]
public string Mc { get; set; } = "";
[JsonPropertyName("payload")]
public string Payload { get; set; } = "";
}
public class FileNameListResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
[JsonPropertyName("file_tree")]
public Dictionary<string, FileNodeInfo>? FileTree { get; set; }
}
public class FileNodeInfo
{
[JsonPropertyName("hash")]
public string? Hash { get; set; }
[JsonPropertyName("category")]
public string? Category { get; set; }
}
public class HostsResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
[JsonPropertyName("hosts_content")]
public string? HostsContent { get; set; }
}
public class FileLinksResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
[JsonPropertyName("file_links")]
public Dictionary<string, FileLinkInfo?>? FileLinks { get; set; }
}
public class FileLinkInfo
{
[JsonPropertyName("url")]
public string? Url { get; set; }
[JsonPropertyName("hash")]
public string? Hash { get; set; }
}
public class GamePermissions
{
[JsonPropertyName("SDGB")]
public bool SDGB { get; set; }
[JsonPropertyName("SDEZ")]
public bool SDEZ { get; set; }
[JsonPropertyName("SDHD")]
public bool SDHD { get; set; }
[JsonPropertyName("SDDT")]
public bool SDDT { get; set; }
[JsonPropertyName("lv")]
public bool LV { get; set; }
}
}