chore(server/game): remove the old c# server
This commit is contained in:
1 parent
917ab66787
commit
63385b7e87
87 files changed
-1437306
No files matched your search
@@ -1,7 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,155 +0,0 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CapySR.ProtoGen;
|
||||
|
||||
// the dump's translation table isn't injective: 11 real names are claimed by two classes each.
|
||||
// one keeps the name, the rest fall back to their obf name. picking wrong means answering on the
|
||||
// wrong cmdid, so score it against independent signals instead of guessing.
|
||||
public sealed class DuplicateResolver
|
||||
{
|
||||
// deliberate calls, keyed by name. add one when the client proves which cmdid is real.
|
||||
private static readonly Dictionary<string, ushort> Overrides = new(StringComparer.Ordinal)
|
||||
{
|
||||
// 321 sits in CmdAvatarType next to DressRelicAvatarCsReq(364); rival 4515 is CmdPlayerReturnType
|
||||
["DressRelicAvatarScRsp"] = 321,
|
||||
|
||||
// UNVERIFIED: 716 { retcode=6 } vs 719 { unk=7, retcode=9 }, both CmdLineupType.
|
||||
// we only ever answer retcode 0, which proto3 omits, so both encode empty. flip if joining hangs.
|
||||
["JoinLineupScRsp"] = 716,
|
||||
};
|
||||
|
||||
private static readonly Regex ServiceEnumPattern = new(@"^enum\s+Cmd(?<service>\w+)Type\s*\{", RegexOptions.Compiled);
|
||||
private static readonly Regex EnumMemberPattern = new(@"=\s*(?<id>\d+)\s*;", RegexOptions.Compiled);
|
||||
|
||||
private readonly Dictionary<ushort, List<string>> _services = [];
|
||||
private readonly HashSet<(ushort Id, string Name)> _packetIds = [];
|
||||
|
||||
public DuplicateResolver(List<ProtoDeclaration> declarations, string packetIdsPath)
|
||||
{
|
||||
foreach (var declaration in declarations.Where(d => d.Kind == DeclarationKind.Enum))
|
||||
{
|
||||
var match = ServiceEnumPattern.Match(declaration.Body[0]);
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var service = match.Groups["service"].Value;
|
||||
|
||||
foreach (var line in declaration.Body.Skip(1))
|
||||
{
|
||||
var member = EnumMemberPattern.Match(line);
|
||||
if (member.Success && ushort.TryParse(member.Groups["id"].Value, out var id) && id != 0)
|
||||
{
|
||||
if (!_services.TryGetValue(id, out var list))
|
||||
{
|
||||
_services[id] = list = [];
|
||||
}
|
||||
|
||||
list.Add(service);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in PacketIdFile.Read(packetIdsPath))
|
||||
{
|
||||
_packetIds.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public ProtoDeclaration PickWinner(List<ProtoDeclaration> candidates) =>
|
||||
candidates.OrderByDescending(Score).ThenBy(c => c.CmdId ?? ushort.MaxValue).First();
|
||||
|
||||
public string Explain(ProtoDeclaration declaration)
|
||||
{
|
||||
var services = declaration.CmdId is { } id && _services.TryGetValue(id, out var list)
|
||||
? string.Join('/', list)
|
||||
: "-";
|
||||
|
||||
return $"CmdID={declaration.CmdId?.ToString() ?? "?"} Type={declaration.PacketType ?? "?"} " +
|
||||
$"service={services} score={Score(declaration)}";
|
||||
}
|
||||
|
||||
private int Score(ProtoDeclaration declaration)
|
||||
{
|
||||
if (declaration.CmdId is not { } id)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
|
||||
if (Overrides.TryGetValue(declaration.Name, out var chosen) && chosen == id)
|
||||
{
|
||||
score += 8;
|
||||
}
|
||||
|
||||
// a message in service "Avatar" should say Avatar in its name
|
||||
if (_services.TryGetValue(id, out var services) &&
|
||||
services.Any(s => declaration.Name.Contains(s, StringComparison.Ordinal)))
|
||||
{
|
||||
score += 4;
|
||||
}
|
||||
|
||||
if (SuffixMatchesType(declaration))
|
||||
{
|
||||
score += 2;
|
||||
}
|
||||
|
||||
if (_packetIds.Contains((id, declaration.Name)))
|
||||
{
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool SuffixMatchesType(ProtoDeclaration declaration)
|
||||
{
|
||||
var expected = declaration.Name switch
|
||||
{
|
||||
var n when n.EndsWith("CsReq", StringComparison.Ordinal) => "Req",
|
||||
var n when n.EndsWith("ScRsp", StringComparison.Ordinal) => "Rsp",
|
||||
var n when n.EndsWith("Notify", StringComparison.Ordinal) => "Notify",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return expected is not null && declaration.PacketType == expected;
|
||||
}
|
||||
}
|
||||
|
||||
// "34 = PlayerLoginCsReq // obf: ..."
|
||||
public static class PacketIdFile
|
||||
{
|
||||
public static IEnumerable<(ushort Id, string Name)> Read(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
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 (name.Length > 0)
|
||||
{
|
||||
yield return (id, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
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<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);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CapySR.ProtoGen;
|
||||
|
||||
// one top-level message/enum plus the metadata comments the dumper put above it
|
||||
public sealed class ProtoDeclaration
|
||||
{
|
||||
public required DeclarationKind Kind { get; init; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
public required List<string> Comments { get; init; }
|
||||
|
||||
// body[0] is always "message Foo {"
|
||||
public required List<string> Body { get; init; }
|
||||
|
||||
public required int SourceLine { get; init; }
|
||||
|
||||
public ushort? CmdId { get; init; }
|
||||
|
||||
public string? PacketType { get; init; }
|
||||
|
||||
public string? ObfuscatedName { get; init; }
|
||||
|
||||
public bool WasRenamed { get; private set; }
|
||||
|
||||
public string OriginalName { get; private set; } = string.Empty;
|
||||
|
||||
public void Rename(string newName)
|
||||
{
|
||||
if (OriginalName.Length == 0)
|
||||
{
|
||||
OriginalName = Name;
|
||||
}
|
||||
|
||||
Body[0] = $"{(Kind == DeclarationKind.Message ? "message" : "enum")} {newName} {{";
|
||||
Name = newName;
|
||||
WasRenamed = true;
|
||||
}
|
||||
|
||||
public void Write(StringBuilder sb)
|
||||
{
|
||||
foreach (var comment in Comments)
|
||||
{
|
||||
sb.AppendLine(comment);
|
||||
}
|
||||
|
||||
if (WasRenamed)
|
||||
{
|
||||
sb.AppendLine($"// renamed from duplicate '{OriginalName}'");
|
||||
}
|
||||
|
||||
foreach (var line in Body)
|
||||
{
|
||||
sb.AppendLine(line);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
public enum DeclarationKind
|
||||
{
|
||||
Message,
|
||||
Enum,
|
||||
}
|
||||
|
||||
// the dump has no package/imports/options, so everything at depth 0 is a declaration
|
||||
public static class ProtoParser
|
||||
{
|
||||
private static readonly Regex DeclarationPattern =
|
||||
new(@"^(?<kind>message|enum)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*\{", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex CmdIdPattern = new(@"^//\s*CmdID:\s*(?<id>\d+)\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex TypePattern = new(@"^//\s*Type:\s*(?<type>\w+)\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex ObfPattern = new(@"^//\s*obf:\s*(?<obf>[A-Za-z_][A-Za-z0-9_]*)\s*$", RegexOptions.Compiled);
|
||||
|
||||
public static List<ProtoDeclaration> Parse(string[] lines)
|
||||
{
|
||||
var declarations = new List<ProtoDeclaration>();
|
||||
var pendingComments = new List<string>();
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var trimmed = lines[i].Trim();
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
pendingComments.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("//", StringComparison.Ordinal))
|
||||
{
|
||||
pendingComments.Add(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
var match = DeclarationPattern.Match(trimmed);
|
||||
if (!match.Success)
|
||||
{
|
||||
pendingComments.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
var start = i;
|
||||
var depth = 0;
|
||||
var body = new List<string>();
|
||||
|
||||
// no string literals in this dump, so raw brace counting is safe
|
||||
do
|
||||
{
|
||||
var current = lines[i];
|
||||
body.Add(current);
|
||||
depth += current.Count(c => c == '{');
|
||||
depth -= current.Count(c => c == '}');
|
||||
i++;
|
||||
}
|
||||
while (depth > 0 && i < lines.Length);
|
||||
|
||||
i--;
|
||||
|
||||
declarations.Add(new ProtoDeclaration
|
||||
{
|
||||
Kind = match.Groups["kind"].Value == "message" ? DeclarationKind.Message : DeclarationKind.Enum,
|
||||
Name = match.Groups["name"].Value,
|
||||
Comments = [.. pendingComments],
|
||||
Body = body,
|
||||
SourceLine = start + 1,
|
||||
CmdId = ExtractCmdId(pendingComments),
|
||||
PacketType = ExtractSingle(pendingComments, TypePattern, "type"),
|
||||
ObfuscatedName = ExtractSingle(pendingComments, ObfPattern, "obf"),
|
||||
});
|
||||
|
||||
pendingComments.Clear();
|
||||
}
|
||||
|
||||
return declarations;
|
||||
}
|
||||
|
||||
private static ushort? ExtractCmdId(List<string> comments) =>
|
||||
ushort.TryParse(ExtractSingle(comments, CmdIdPattern, "id"), out var value) ? value : null;
|
||||
|
||||
private static string? ExtractSingle(List<string> comments, Regex pattern, string group)
|
||||
{
|
||||
foreach (var comment in comments)
|
||||
{
|
||||
var match = pattern.Match(comment);
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Groups[group].Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<#
|
||||
English-patches a CN Star Rail client.
|
||||
|
||||
The language table lives in a .bytes file under
|
||||
StarRail_Data/StreamingAssets/DesignData/Windows. It looks like:
|
||||
|
||||
cn..Chinese(PRC) en..English jp..Japanese kr..Korean
|
||||
os en kr jp en cn cn cn <- voice block
|
||||
os cn en kr jp en cn cn cn <- text block
|
||||
|
||||
Each entry is a length byte (0x02) followed by two ASCII chars, so cn -> en
|
||||
is one byte per entry. The first "cn cn" pair is voice, the second is text.
|
||||
|
||||
Every change is backed up next to the original as *.capysr-backup.
|
||||
Run with -Revert to put the originals back.
|
||||
#>
|
||||
param(
|
||||
[string]$ClientPath,
|
||||
[ValidateSet('en', 'cn', 'jp', 'kr')] [string]$Text = 'en',
|
||||
[ValidateSet('en', 'cn', 'jp', 'kr')] [string]$Voice = '',
|
||||
[switch]$Revert
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $ClientPath) {
|
||||
$configPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'config\config.json'
|
||||
if (Test-Path $configPath) {
|
||||
$ClientPath = (Get-Content $configPath -Raw | ConvertFrom-Json).Client.Path
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $ClientPath -or -not (Test-Path $ClientPath)) {
|
||||
Write-Host "client folder not found. pass -ClientPath <folder with StarRail.exe>" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$dir = Join-Path $ClientPath 'StarRail_Data\StreamingAssets\DesignData\Windows'
|
||||
if (-not (Test-Path $dir)) {
|
||||
Write-Host "no DesignData at $dir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($Revert) {
|
||||
$backups = Get-ChildItem $dir -Filter '*.capysr-backup' -ErrorAction SilentlyContinue
|
||||
if (-not $backups) { Write-Host 'nothing to revert' -ForegroundColor Yellow; exit 0 }
|
||||
|
||||
foreach ($b in $backups) {
|
||||
$target = $b.FullName -replace '\.capysr-backup$', ''
|
||||
Copy-Item $b.FullName $target -Force
|
||||
Remove-Item $b.FullName -Force
|
||||
Write-Host " reverted $(Split-Path $target -Leaf)" -ForegroundColor Green
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
$patched = 0
|
||||
|
||||
foreach ($file in Get-ChildItem $dir -Filter '*.bytes') {
|
||||
$data = [System.IO.File]::ReadAllBytes($file.FullName)
|
||||
|
||||
# the table sits just past the literal "Korean"
|
||||
$marker = [System.Text.Encoding]::ASCII.GetBytes('Korean')
|
||||
$at = -1
|
||||
for ($i = 0; $i -le $data.Length - $marker.Length; $i++) {
|
||||
$hit = $true
|
||||
for ($j = 0; $j -lt $marker.Length; $j++) {
|
||||
if ($data[$i + $j] -ne $marker[$j]) { $hit = $false; break }
|
||||
}
|
||||
if ($hit) { $at = $i; break }
|
||||
}
|
||||
if ($at -lt 0) { continue }
|
||||
|
||||
# collect the "02 'c' 'n'" entries that follow, in order
|
||||
$entries = @()
|
||||
for ($i = $at + 6; $i -lt [Math]::Min($at + 140, $data.Length - 3); $i++) {
|
||||
if ($data[$i] -eq 0x02 -and $data[$i + 1] -eq 0x63 -and $data[$i + 2] -eq 0x6E) {
|
||||
$entries += ($i + 1)
|
||||
}
|
||||
}
|
||||
|
||||
# voice block then text block; the last two entries of each are the pair
|
||||
if ($entries.Count -lt 6) { continue }
|
||||
|
||||
Write-Host " $($file.Name): $($entries.Count) cn entries at $($entries -join ', ')" -ForegroundColor DarkGray
|
||||
|
||||
$backup = "$($file.FullName).capysr-backup"
|
||||
if (-not (Test-Path $backup)) { Copy-Item $file.FullName $backup }
|
||||
|
||||
function Set-Code($offset, $code) {
|
||||
$bytes = [System.Text.Encoding]::ASCII.GetBytes($code)
|
||||
$data[$offset] = $bytes[0]
|
||||
$data[$offset + 1] = $bytes[1]
|
||||
}
|
||||
|
||||
# each block is: standalone cn, then the "cn cn" pair.
|
||||
# block 1 pair = entries 1,2 (voice); block 2 pair = entries 5,6 (text).
|
||||
if ($Voice) {
|
||||
Set-Code $entries[1] $Voice
|
||||
Set-Code $entries[2] $Voice
|
||||
}
|
||||
|
||||
Set-Code $entries[5] $Text
|
||||
Set-Code $entries[6] $Text
|
||||
|
||||
[System.IO.File]::WriteAllBytes($file.FullName, $data)
|
||||
$patched++
|
||||
Write-Host " text -> $Text$(if ($Voice) { ", voice -> $Voice" })" -ForegroundColor Green
|
||||
}
|
||||
|
||||
if ($patched -eq 0) {
|
||||
Write-Host 'no language table found - the client layout may have changed' -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "patched $patched file(s). backups are *.capysr-backup" -ForegroundColor Cyan
|
||||
Write-Host 'revert with: tools\patch-client-language.ps1 -Revert' -ForegroundColor DarkGray
|
||||
Reference in new issue
Block a user