Files
CapySR/tools/CapySR.ProtoGen/Program.cs
T

419 lines
12 KiB
C#
Raw Normal View History

2026-09-07 21:28:11 -07:00
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;
}
2026-09-07 21:57:28 -07:00
var sourceProto = Path.Combine(repoRoot, "stuff", "TurnBaseGameProto", "Raw", "StarRail.proto");
var packetIds = Path.Combine(repoRoot, "stuff", "TurnBaseGameProto", "Raw", "packetIds.txt");
2026-09-07 21:28:11 -07:00
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<string>(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<ushort, string>
{
// 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("// <auto-generated>");
sb.AppendLine("// generated by tools/CapySR.ProtoGen - do not edit");
sb.AppendLine("// </auto-generated>");
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 <path>, 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<ushort, ProtoDeclaration>();
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<string> 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<ushort, ProtoDeclaration> 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<KeyValuePair<ushort, ProtoDeclaration>> ordered)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated>");
sb.AppendLine("// generated by tools/CapySR.ProtoGen - do not edit");
sb.AppendLine("// </auto-generated>");
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<KeyValuePair<ushort, ProtoDeclaration>> ordered)
{
// parallel arrays; parsers get resolved from the descriptor at runtime
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated>");
sb.AppendLine("// generated by tools/CapySR.ProtoGen - do not edit");
sb.AppendLine("// </auto-generated>");
sb.AppendLine();
sb.AppendLine("namespace CapySR.Protocol;");
sb.AppendLine();
sb.AppendLine("public static class CmdIdTable");
sb.AppendLine("{");
sb.AppendLine(" public static ReadOnlySpan<ushort> 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<string> 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);
}