feat: support CNBETAWin4.5.53

This commit is contained in:
capyb2222 committed 2026-09-07 22:19:20 -07:00
1 parent b87874f2fe
commit e344e699de
10 files changed
+260 -27

No files matched your search

+3 -7
View File
@@ -10,10 +10,6 @@ logs/
data/
freesr-data.json
# game data and reference servers are separate checkouts, not part of CapySR
turnbasedgamedata/
March7thHoney-OpenSource/
pearl-sr/
reference-robinsr/
reference-himeko-nova-sr/
TurnBaseGameProto/
stuff/
FetchHotfix/
+19 -5
View File
@@ -1,7 +1,7 @@
# CapySR
A Honkai: Star Rail server reimplementation rewritten in C#.
Current version: `CNBETAWin4.5.52`
Current version: `CNBETAWin4.5.53`
Goals: a full combat system including combat events, and
[srtools](https://srtools.neonteam.dev/) support. Currency Wars and Divergent Universe might be focused on in the far future.
@@ -11,6 +11,11 @@ Goals: a full combat system including combat events, and
- .NET 10 SDK
- `protoc` (only to regenerate the protocol)
Everything the server reads but does not own lives in `stuff/` and is not tracked here: the
game data it merges (`stuff/turnbasedgamedata/ExcelOutput` from Dimbreath and
`stuff/pearl-sr/resources` for the beta overlay), the proto dump, and the reference servers.
`Data.Sources` in `config/config.json` points at the first two, in priority order.
## Running
```
@@ -71,6 +76,17 @@ rotation. Challenges always play their own stage.
### Hotfix URLs
Unknown versions are fetched from the official dispatch once and cached in
`config/versions.json`. A beta dispatch stops answering as soon as its window closes, so a
client newer than anything cached cannot be looked up by anyone any more. When that happens
the closest older build of the same channel is lent out, which keeps a packaged client
booting: an empty `ex_resource_url` hangs it at about 99% before it ever reaches the game
server. Turn `Hotfix.EnableDesignDataUpdate` off while a version is running on borrowed urls,
or it will try to download design data that does not match its build.
The version a client will ask for, and its dispatch seed, are in
`StarRail_Data/StreamingAssets/BinaryVersion.bytes`.
For OS:
```json
{
@@ -99,11 +115,9 @@ dotnet run --project tools/CapySR.ProtoGen
```
The generated protocol is committed, so this is only needed after a proto dump changes. Clone
[TurnBaseGameProto](https://github.com/Mar7thLover/TurnBaseGameProto) next to the solution first;
game data (`turnbasedgamedata`, `pearl-sr/resources`) and the reference servers are separate
checkouts too and are not tracked here.
[TurnBaseGameProto](https://github.com/Mar7thLover/TurnBaseGameProto) into `stuff/` first.
Reads `TurnBaseGameProto/Raw/StarRail.proto`, resolves the 11 duplicate type names the dump's
Reads `stuff/TurnBaseGameProto/Raw/StarRail.proto`, resolves the 11 duplicate type names the dump's
translation pass produces, runs protoc, and writes `CmdId.g.cs` / `CmdIdTable.g.cs`. Duplicate
resolution is scored against the `CmdXxxType` service enums and `packetIds.txt`; deliberate calls
live in `DuplicateResolver.Overrides`.
+2 -2
View File
@@ -36,8 +36,8 @@
},
"Data": {
"Sources": [
"turnbasedgamedata/ExcelOutput",
"pearl-sr/resources"
"stuff/turnbasedgamedata/ExcelOutput",
"stuff/pearl-sr/resources"
]
},
"Client": {
+6
View File
@@ -82,5 +82,11 @@
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_16343198_0b109c2dab94_dd42d0213ac00f",
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_16317476_035e0a79224b_ed91499b6ef70b",
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_0_40d2ce0253_c61ba99f70b885"
},
"CNBETAWin4.5.53": {
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_16397599_dc132a900809_7e7a4d9792c13e",
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_16399740_f7f17473a4be_cd161900e78e53",
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_16399471_bf17e3d5c3d7_5605fcd6b48271",
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_0_40d2ce0253_c61ba99f70b885"
}
}
+113
View File
@@ -0,0 +1,113 @@
using System.Diagnostics.CodeAnalysis;
namespace CapySR.Common;
// A client version reads as a channel followed by dotted numbers: CNBETAWin4.5.53.
// Ordering them matters because a beta dispatch stops answering once its window closes,
// so a build newer than anything on hand has to fall back to the closest one.
public readonly struct GameVersion(string channel, int[] parts) : IComparable<GameVersion>
{
private static readonly char[] Digits = "0123456789".ToCharArray();
public string Channel { get; } = channel;
public int[] Parts { get; } = parts;
public static bool TryParse(string? version, out GameVersion parsed)
{
parsed = default;
if (string.IsNullOrEmpty(version))
{
return false;
}
var digit = version.IndexOfAny(Digits);
if (digit <= 0)
{
return false;
}
var pieces = version[digit..].Split('.');
var parts = new int[pieces.Length];
for (var i = 0; i < pieces.Length; i++)
{
if (!int.TryParse(pieces[i], out parts[i]))
{
return false;
}
}
parsed = new GameVersion(version[..digit], parts);
return true;
}
public bool SameChannel(GameVersion other) =>
Channel.Equals(other.Channel, StringComparison.OrdinalIgnoreCase);
public int CompareTo(GameVersion other)
{
var length = Math.Max(Parts.Length, other.Parts.Length);
for (var i = 0; i < length; i++)
{
var mine = i < Parts.Length ? Parts[i] : 0;
var theirs = i < other.Parts.Length ? other.Parts[i] : 0;
if (mine != theirs)
{
return mine.CompareTo(theirs);
}
}
return 0;
}
// the closest build of the same channel: the newest one no later than what was asked
// for, or failing that the newest there is. never crosses channels.
public static bool TryFindNearest(IEnumerable<string> candidates, string wanted, [NotNullWhen(true)] out string? nearest)
{
nearest = null;
if (!TryParse(wanted, out var target))
{
return false;
}
GameVersion older = default, any = default;
var haveOlder = false;
var haveAny = false;
foreach (var candidate in candidates)
{
if (!TryParse(candidate, out var version) || !version.SameChannel(target))
{
continue;
}
if (!haveAny || version.CompareTo(any) > 0)
{
any = version;
haveAny = true;
if (!haveOlder)
{
nearest = candidate;
}
}
if (version.CompareTo(target) <= 0 && (!haveOlder || version.CompareTo(older) > 0))
{
older = version;
haveOlder = true;
nearest = candidate;
}
}
return nearest is not null;
}
public override string ToString() => Channel + string.Join('.', Parts);
}
+2 -2
View File
@@ -121,8 +121,8 @@ public sealed class DataConfig
private static readonly string[] DefaultSources =
[
"turnbasedgamedata/ExcelOutput",
"pearl-sr/resources",
"stuff/turnbasedgamedata/ExcelOutput",
"stuff/pearl-sr/resources",
];
public IEnumerable<string> ResolvedSources => (Sources.Count > 0 ? Sources : DefaultSources.ToList())
+34 -8
View File
@@ -38,6 +38,9 @@ public sealed class HotfixCache
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly string _path;
// versions already told about, so a borrowed hotfix is reported once and not per request
private readonly HashSet<string> _borrowed = new(StringComparer.OrdinalIgnoreCase);
private Dictionary<string, VersionHotfix> _versions = new(StringComparer.OrdinalIgnoreCase);
public HotfixCache(HotfixConfig config, ILogger<HotfixCache> logger)
@@ -90,11 +93,8 @@ public sealed class HotfixCache
return cached;
}
if (!_config.AutoFetch)
if (_config.AutoFetch)
{
return cached;
}
await _lock.WaitAsync();
try
@@ -105,21 +105,47 @@ public sealed class HotfixCache
}
var fetched = await FetchAsync(version, dispatchSeed);
if (fetched is null)
{
return cached;
}
if (fetched is not null)
{
_versions[version] = fetched;
Save();
return fetched;
}
}
finally
{
_lock.Release();
}
}
return cached is { IsUsable: true } ? cached : Nearest(version);
}
// A beta dispatch stops answering once its window closes, so a client on a build newer
// than anything cached can never be looked up. Packaged clients ship their own resources
// and only need the urls to be present, so lend them the closest build's.
public VersionHotfix? Nearest(string version)
{
var usable = _versions.Where(v => v.Value.IsUsable).Select(v => v.Key);
if (!GameVersion.TryFindNearest(usable, version, out var fallback))
{
return null;
}
if (_borrowed.Add(version))
{
_logger.LogWarning(
"no hotfix for {Version} and upstream will not serve it; lending {Fallback}'s urls. " +
"add the real ones to {Path}, or keep Hotfix:EnableDesignDataUpdate off so the " +
"client boots on the resources it shipped with",
version, fallback, _path);
}
return _versions[fallback];
}
private async Task<VersionHotfix?> FetchAsync(string version, string dispatchSeed)
{
var host = SelectHost(version);
+1 -1
View File
@@ -90,7 +90,7 @@ if (-not $clientDir -or -not (Test-Path $clientDir)) {
$launcher = Join-Path $clientDir 'launcher.exe'
$dll = Join-Path $clientDir 'hkrpg.dll'
$patch = Join-Path $root 'pearl-sr\launcher'
$patch = Join-Path $root 'stuff\pearl-sr\launcher'
# keep the injector in step with the copy in pearl-sr
foreach ($file in 'launcher.exe', 'hkrpg.dll') {
+78
View File
@@ -0,0 +1,78 @@
using CapySR.Common;
using Xunit;
namespace CapySR.Tests;
// A beta dispatch stops answering once its window closes, so a client that updated past
// everything in config/versions.json has to be served from the closest build on hand.
public class GameVersionTests
{
private static readonly string[] Cached =
[
"OSBETAWin4.4.51",
"CNBETAWin4.4.53",
"CNBETAWin4.5.51",
"CNBETAWin4.5.52",
];
[Fact]
public void VersionsParseIntoAChannelAndNumbers()
{
Assert.True(GameVersion.TryParse("CNBETAWin4.5.53", out var version));
Assert.Equal("CNBETAWin", version.Channel);
Assert.Equal([4, 5, 53], version.Parts);
Assert.False(GameVersion.TryParse("nonsense", out _));
Assert.False(GameVersion.TryParse("CNBETAWin4.5.x", out _));
Assert.False(GameVersion.TryParse("", out _));
Assert.False(GameVersion.TryParse(null, out _));
}
[Theory]
[InlineData("CNBETAWin4.5.52", "CNBETAWin4.5.53", -1)]
[InlineData("CNBETAWin4.5.53", "CNBETAWin4.5.52", 1)]
[InlineData("CNBETAWin4.5.53", "CNBETAWin4.5.53", 0)]
[InlineData("CNBETAWin4.9.0", "CNBETAWin4.10.0", -1)]
// a missing trailing part counts as zero, so 4.5 and 4.5.0 are the same build
[InlineData("CNBETAWin4.5", "CNBETAWin4.5.0", 0)]
public void VersionsCompareNumerically(string left, string right, int expected)
{
Assert.True(GameVersion.TryParse(left, out var a));
Assert.True(GameVersion.TryParse(right, out var b));
Assert.Equal(expected, Math.Sign(a.CompareTo(b)));
}
// the case that matters: the client updated past anything the dispatch will still serve
[Fact]
public void ANewerClientBorrowsTheClosestOlderBuild()
{
Assert.True(GameVersion.TryFindNearest(Cached, "CNBETAWin4.5.53", out var nearest));
Assert.Equal("CNBETAWin4.5.52", nearest);
}
[Fact]
public void ChannelsNeverBorrowFromEachOther()
{
Assert.True(GameVersion.TryFindNearest(Cached, "OSBETAWin4.9.99", out var os));
Assert.Equal("OSBETAWin4.4.51", os);
Assert.False(GameVersion.TryFindNearest(Cached, "CNPRODWin4.5.53", out _));
Assert.False(GameVersion.TryFindNearest(Cached, "nonsense", out _));
Assert.False(GameVersion.TryFindNearest([], "CNBETAWin4.5.53", out _));
}
// nothing older to fall back on, so the newest known build beats having no urls at all
[Fact]
public void AnOlderClientStillGetsUrls()
{
Assert.True(GameVersion.TryFindNearest(Cached, "CNBETAWin3.0.0", out var nearest));
Assert.Equal("CNBETAWin4.5.52", nearest);
}
[Fact]
public void AnExactMatchIsPreferredOverEverythingElse()
{
Assert.True(GameVersion.TryFindNearest(Cached, "CNBETAWin4.5.51", out var nearest));
Assert.Equal("CNBETAWin4.5.51", nearest);
}
}
+2 -2
View File
@@ -12,8 +12,8 @@ if (repoRoot is null)
return 1;
}
var sourceProto = Path.Combine(repoRoot, "TurnBaseGameProto", "Raw", "StarRail.proto");
var packetIds = Path.Combine(repoRoot, "TurnBaseGameProto", "Raw", "packetIds.txt");
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");