feat: support CNBETAWin4.5.53
This commit is contained in:
1 parent
b87874f2fe
commit
e344e699de
10 files changed
+273
-40
No files matched your search
+3
-7
@@ -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/
|
||||
@@ -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`.
|
||||
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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,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<VersionHotfix?> FetchAsync(string version, string dispatchSeed)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user