using System.Diagnostics; using System.Text; using CapySR.ProtoGen; // dumped proto -> deduped proto -> protoc --csharp_out -> cmdid tables. // dotnet run --project tools/CapySR.ProtoGen var repoRoot = FindRepoRoot(); if (repoRoot is null) { Console.Error.WriteLine("could not locate repo root (no Directory.Build.props found walking up)"); return 1; } var sourceProto = Path.Combine(repoRoot, "stuff", "TurnBaseGameProto", "Raw", "StarRail.proto"); var packetIds = Path.Combine(repoRoot, "stuff", "TurnBaseGameProto", "Raw", "packetIds.txt"); var protocolProject = Path.Combine(repoRoot, "src", "CapySR.Protocol"); var cleanedProtoDir = Path.Combine(protocolProject, "Proto"); var cleanedProto = Path.Combine(cleanedProtoDir, "StarRail.proto"); var generatedDir = Path.Combine(protocolProject, "Generated"); if (!File.Exists(sourceProto)) { Console.Error.WriteLine($"source proto not found: {sourceProto}"); return 1; } Directory.CreateDirectory(cleanedProtoDir); Directory.CreateDirectory(generatedDir); Console.WriteLine($"reading {Path.GetRelativePath(repoRoot, sourceProto)}"); var lines = File.ReadAllLines(sourceProto); var declarations = ProtoParser.Parse(lines); var messages = declarations.Count(d => d.Kind == DeclarationKind.Message); var enums = declarations.Count(d => d.Kind == DeclarationKind.Enum); Console.WriteLine($"parsed {declarations.Count} top-level declarations ({messages} messages, {enums} enums)"); // -- 2. resolve duplicate names ------------------------------------------- var taken = new HashSet(StringComparer.Ordinal); foreach (var declaration in declarations) { taken.Add(declaration.Name); } var resolver = new DuplicateResolver(declarations, packetIds); var renames = 0; foreach (var group in declarations.GroupBy(d => d.Name, StringComparer.Ordinal).Where(g => g.Count() > 1).ToList()) { var candidates = group.ToList(); var winner = resolver.PickWinner(candidates); Console.WriteLine($" duplicate '{group.Key}':"); foreach (var candidate in candidates) { var marker = ReferenceEquals(candidate, winner) ? "keep" : " "; Console.WriteLine($" [{marker}] line {candidate.SourceLine,5} {resolver.Explain(candidate)}"); } foreach (var loser in candidates.Where(c => !ReferenceEquals(c, winner))) { var replacement = ChooseReplacementName(loser, taken); Console.WriteLine($" -> renamed to '{replacement}'"); loser.Rename(replacement); taken.Add(replacement); renames++; } } Console.WriteLine($"resolved {renames} duplicate name(s)"); // names the dump's translation got wrong, keyed by cmdid var knownRenames = new Dictionary { // the dump has 5 lineup requests but calls 719 a second JoinLineupScRsp and has no // ReplaceLineupScRsp at all. pearl-sr's dump names it ReplaceLineupScRsp. [719] = "ReplaceLineupScRsp", }; foreach (var (cmdId, correct) in knownRenames) { var target = declarations.FirstOrDefault(d => d.CmdId == cmdId); if (target is null) { Console.WriteLine($" warning: no message with CmdID {cmdId} to rename to '{correct}'"); continue; } if (declarations.Any(d => d.Name == correct)) { Console.WriteLine($" warning: '{correct}' already exists, leaving CmdID {cmdId} as '{target.Name}'"); continue; } Console.WriteLine($" rename: CmdID {cmdId} '{target.Name}' -> '{correct}'"); target.Rename(correct); taken.Add(correct); } var stillDuplicated = declarations .GroupBy(d => d.Name, StringComparer.Ordinal) .Where(g => g.Count() > 1) .Select(g => g.Key) .ToList(); if (stillDuplicated.Count > 0) { Console.Error.WriteLine($"unresolved duplicates remain: {string.Join(", ", stillDuplicated)}"); return 1; } // -- 3. emit the cleaned proto -------------------------------------------- var sb = new StringBuilder(6 * 1024 * 1024); sb.AppendLine("// "); sb.AppendLine("// generated by tools/CapySR.ProtoGen - do not edit"); sb.AppendLine("// "); sb.AppendLine(); sb.AppendLine("syntax = \"proto3\";"); sb.AppendLine(); sb.AppendLine("option csharp_namespace = \"CapySR.Protocol\";"); sb.AppendLine(); foreach (var declaration in declarations) { declaration.Write(sb); } File.WriteAllText(cleanedProto, sb.ToString()); Console.WriteLine($"wrote {Path.GetRelativePath(repoRoot, cleanedProto)} ({sb.Length / 1024} KiB)"); // -- 4. run protoc --------------------------------------------------------- var protoc = ResolveProtoc(args); if (protoc is null) { Console.Error.WriteLine("protoc not found. Pass --protoc , set PROTOC, or put it on PATH."); return 1; } Console.WriteLine($"running {protoc}"); var protocResult = Run(protoc, [ $"--csharp_out={generatedDir}", $"--proto_path={cleanedProtoDir}", "StarRail.proto", ]); if (protocResult.ExitCode != 0) { Console.Error.WriteLine("protoc failed:"); Console.Error.WriteLine(protocResult.Output); return 1; } var generatedCs = Path.Combine(generatedDir, "StarRail.cs"); if (File.Exists(generatedCs)) { Console.WriteLine($"wrote {Path.GetRelativePath(repoRoot, generatedCs)} ({new FileInfo(generatedCs).Length / 1024} KiB)"); } // -- 5. emit the CmdId tables --------------------------------------------- var withCmdId = declarations .Where(d => d.Kind == DeclarationKind.Message && d.CmdId is > 0) .OrderBy(d => d.CmdId!.Value) .ToList(); var byId = new Dictionary(); foreach (var declaration in withCmdId) { if (!byId.TryAdd(declaration.CmdId!.Value, declaration)) { Console.WriteLine( $" warning: CmdID {declaration.CmdId} claimed by both '{byId[declaration.CmdId!.Value].Name}' " + $"and '{declaration.Name}' - keeping the first"); } } var ordered = byId.OrderBy(kv => kv.Key).ToList(); Console.WriteLine($"found {ordered.Count} messages carrying a CmdID"); CrossCheckPacketIds(packetIds, byId); WriteCmdIdEnum(Path.Combine(generatedDir, "CmdId.g.cs"), ordered); WriteCmdIdTable(Path.Combine(generatedDir, "CmdIdTable.g.cs"), ordered); Console.WriteLine("done"); return 0; // --------------------------------------------------------------------------- static string ChooseReplacementName(ProtoDeclaration declaration, HashSet taken) { if (declaration.ObfuscatedName is { Length: > 0 } obf && !taken.Contains(obf)) { return obf; } for (var i = 2; ; i++) { var candidate = $"{declaration.Name}_Dup{i}"; if (!taken.Contains(candidate)) { return candidate; } } } static void CrossCheckPacketIds(string path, Dictionary byId) { if (!File.Exists(path)) { Console.WriteLine(" (packetIds.txt not found, skipping cross-check)"); return; } int agree = 0, disagree = 0, missing = 0; foreach (var line in File.ReadLines(path)) { var trimmed = line.Trim(); if (trimmed.Length == 0 || trimmed.StartsWith("//", StringComparison.Ordinal)) { continue; } var eq = trimmed.IndexOf('='); if (eq < 0 || !ushort.TryParse(trimmed[..eq].Trim(), out var id)) { continue; } var rest = trimmed[(eq + 1)..]; var comment = rest.IndexOf("//", StringComparison.Ordinal); var name = (comment >= 0 ? rest[..comment] : rest).Trim(); if (!byId.TryGetValue(id, out var declaration)) { missing++; } else if (string.Equals(declaration.Name, name, StringComparison.Ordinal)) { agree++; } else { disagree++; } } Console.WriteLine($" packetIds.txt cross-check: {agree} agree, {disagree} disagree, {missing} not in proto"); } static void WriteCmdIdEnum(string path, List> ordered) { var sb = new StringBuilder(); sb.AppendLine("// "); sb.AppendLine("// generated by tools/CapySR.ProtoGen - do not edit"); sb.AppendLine("// "); sb.AppendLine(); sb.AppendLine("namespace CapySR.Protocol;"); sb.AppendLine(); sb.AppendLine("public enum CmdId : ushort"); sb.AppendLine("{"); sb.AppendLine(" None = 0,"); foreach (var (id, declaration) in ordered) { sb.AppendLine($" {declaration.Name} = {id},"); } sb.AppendLine("}"); File.WriteAllText(path, sb.ToString()); Console.WriteLine($"wrote {Path.GetFileName(path)} ({ordered.Count} entries)"); } static void WriteCmdIdTable(string path, List> ordered) { // parallel arrays; parsers get resolved from the descriptor at runtime var sb = new StringBuilder(); sb.AppendLine("// "); sb.AppendLine("// generated by tools/CapySR.ProtoGen - do not edit"); sb.AppendLine("// "); sb.AppendLine(); sb.AppendLine("namespace CapySR.Protocol;"); sb.AppendLine(); sb.AppendLine("public static class CmdIdTable"); sb.AppendLine("{"); sb.AppendLine(" public static ReadOnlySpan Ids =>"); sb.AppendLine(" ["); AppendWrapped(sb, ordered.Select(kv => kv.Key.ToString())); sb.AppendLine(" ];"); sb.AppendLine(); sb.AppendLine(" public static readonly string[] Names ="); sb.AppendLine(" ["); AppendWrapped(sb, ordered.Select(kv => $"\"{kv.Value.Name}\"")); sb.AppendLine(" ];"); sb.AppendLine("}"); File.WriteAllText(path, sb.ToString()); Console.WriteLine($"wrote {Path.GetFileName(path)}"); } static void AppendWrapped(StringBuilder sb, IEnumerable values) { var line = new StringBuilder(" "); foreach (var value in values) { if (line.Length + value.Length + 2 > 116) { sb.AppendLine(line.ToString()); line.Clear().Append(" "); } line.Append(value).Append(", "); } if (line.ToString().Trim().Length > 0) { sb.AppendLine(line.ToString()); } } static string? FindRepoRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null) { if (File.Exists(Path.Combine(dir.FullName, "Directory.Build.props"))) { return dir.FullName; } dir = dir.Parent; } return null; } static string? ResolveProtoc(string[] args) { var index = Array.IndexOf(args, "--protoc"); if (index >= 0 && index + 1 < args.Length && File.Exists(args[index + 1])) { return args[index + 1]; } if (Environment.GetEnvironmentVariable("PROTOC") is { Length: > 0 } fromEnv && File.Exists(fromEnv)) { return fromEnv; } var exe = OperatingSystem.IsWindows() ? "protoc.exe" : "protoc"; foreach (var pathDir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) { try { var candidate = Path.Combine(pathDir.Trim('"'), exe); if (File.Exists(candidate)) { return candidate; } } catch (ArgumentException) { } } // protoc-*/bin/protoc.exe at a drive root, how it usually gets unzipped foreach (var drive in DriveInfo.GetDrives().Where(d => d.IsReady)) { try { foreach (var dir in Directory.EnumerateDirectories(drive.RootDirectory.FullName, "protoc-*")) { var candidate = Path.Combine(dir, "bin", exe); if (File.Exists(candidate)) { return candidate; } } } catch (Exception e) when (e is UnauthorizedAccessException or IOException) { } } return null; } static (int ExitCode, string Output) Run(string fileName, string[] arguments) { var info = new ProcessStartInfo(fileName) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; foreach (var argument in arguments) { info.ArgumentList.Add(argument); } using var process = Process.Start(info)!; var stdout = process.StandardOutput.ReadToEnd(); var stderr = process.StandardError.ReadToEnd(); process.WaitForExit(); return (process.ExitCode, stdout + stderr); }