420 lines
12 KiB
C#
420 lines
12 KiB
C#
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, "TurnBaseGameProto", "Raw", "StarRail.proto");
|
|
var packetIds = Path.Combine(repoRoot, "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<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);
|
|
}
|