feat(init): server has been rewritten

This commit is contained in:
capyb2222 committed 2026-09-07 22:19:20 -07:00
1 parent 3647ed19c4
commit b87874f2fe
84 files changed
+1437031 -1

No files matched your search

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
</Project>
+155
View File
@@ -0,0 +1,155 @@
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);
}
}
}
}
+419
View File
@@ -0,0 +1,419 @@
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);
}
+158
View File
@@ -0,0 +1,158 @@
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;
}
}
+118
View File
@@ -0,0 +1,118 @@
<#
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