From e344e699de28dbf71aca253da7fb5f4bd17c3dfb Mon Sep 17 00:00:00 2001 From: capyb2222 Date: Mon, 7 Sep 2026 21:57:28 -0700 Subject: [PATCH] feat: support CNBETAWin4.5.53 --- .gitignore | 10 +-- README.md | 24 ++++-- config/config.json | 6 +- config/versions.json | 8 +- src/CapySR.Common/GameVersion.cs | 113 ++++++++++++++++++++++++++++ src/CapySR.Common/ServerConfig.cs | 4 +- src/CapySR.SdkServer/HotfixCache.cs | 64 +++++++++++----- start.ps1 | 2 +- tests/CapySR.Tests/HotfixTests.cs | 78 +++++++++++++++++++ tools/CapySR.ProtoGen/Program.cs | 4 +- 10 files changed, 273 insertions(+), 40 deletions(-) create mode 100644 src/CapySR.Common/GameVersion.cs create mode 100644 tests/CapySR.Tests/HotfixTests.cs diff --git a/.gitignore b/.gitignore index 4b5b9c8..ebce7f5 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index a9e4c67..bae6980 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/config/config.json b/config/config.json index e6ab3e5..d372612 100644 --- a/config/config.json +++ b/config/config.json @@ -36,11 +36,11 @@ }, "Data": { "Sources": [ - "turnbasedgamedata/ExcelOutput", - "pearl-sr/resources" + "stuff/turnbasedgamedata/ExcelOutput", + "stuff/pearl-sr/resources" ] }, "Client": { "Path": "C:\\Users\\bachk\\Downloads\\StarRail_4.5.52_CN_ReversedRooms" } -} \ No newline at end of file +} diff --git a/config/versions.json b/config/versions.json index 35c94de..2f32d1f 100644 --- a/config/versions.json +++ b/config/versions.json @@ -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" } -} \ No newline at end of file +} diff --git a/src/CapySR.Common/GameVersion.cs b/src/CapySR.Common/GameVersion.cs new file mode 100644 index 0000000..6c7ea96 --- /dev/null +++ b/src/CapySR.Common/GameVersion.cs @@ -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 +{ + 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 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); +} diff --git a/src/CapySR.Common/ServerConfig.cs b/src/CapySR.Common/ServerConfig.cs index 6db84a2..3eda5d2 100644 --- a/src/CapySR.Common/ServerConfig.cs +++ b/src/CapySR.Common/ServerConfig.cs @@ -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 ResolvedSources => (Sources.Count > 0 ? Sources : DefaultSources.ToList()) diff --git a/src/CapySR.SdkServer/HotfixCache.cs b/src/CapySR.SdkServer/HotfixCache.cs index ef15b4d..1885ed9 100644 --- a/src/CapySR.SdkServer/HotfixCache.cs +++ b/src/CapySR.SdkServer/HotfixCache.cs @@ -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 _borrowed = new(StringComparer.OrdinalIgnoreCase); + private Dictionary _versions = new(StringComparer.OrdinalIgnoreCase); public HotfixCache(HotfixConfig config, ILogger logger) @@ -90,34 +93,57 @@ public sealed class HotfixCache return cached; } - if (!_config.AutoFetch) + if (_config.AutoFetch) { - return cached; - } + await _lock.WaitAsync(); - await _lock.WaitAsync(); - - try - { - if (_versions.TryGetValue(version, out cached) && cached.IsUsable) + try { - return cached; - } + if (_versions.TryGetValue(version, out cached) && cached.IsUsable) + { + return cached; + } - var fetched = await FetchAsync(version, dispatchSeed); - if (fetched is null) + var fetched = await FetchAsync(version, dispatchSeed); + + if (fetched is not null) + { + _versions[version] = fetched; + Save(); + return fetched; + } + } + finally { - return cached; + _lock.Release(); } + } - _versions[version] = fetched; - Save(); - return fetched; - } - finally + 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)) { - _lock.Release(); + 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 FetchAsync(string version, string dispatchSeed) diff --git a/start.ps1 b/start.ps1 index 75df72b..44ff3f4 100644 --- a/start.ps1 +++ b/start.ps1 @@ -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') { diff --git a/tests/CapySR.Tests/HotfixTests.cs b/tests/CapySR.Tests/HotfixTests.cs new file mode 100644 index 0000000..3cd93dc --- /dev/null +++ b/tests/CapySR.Tests/HotfixTests.cs @@ -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); + } +} diff --git a/tools/CapySR.ProtoGen/Program.cs b/tools/CapySR.ProtoGen/Program.cs index 59c2ff6..955be95 100644 --- a/tools/CapySR.ProtoGen/Program.cs +++ b/tools/CapySR.ProtoGen/Program.cs @@ -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");