Files
CapySR/src/CapySR.SdkServer/HotfixCache.cs
T

217 lines
7.2 KiB
C#
Raw Normal View History

2026-09-07 21:28:11 -07:00
using System.Text.Json;
using System.Text.Json.Serialization;
using CapySR.Common;
using CapySR.Protocol;
using Google.Protobuf;
namespace CapySR.SdkServer;
public sealed class VersionHotfix
{
[JsonPropertyName("asset_bundle_url")]
public string AssetBundleUrl { get; set; } = string.Empty;
[JsonPropertyName("ex_resource_url")]
public string ExResourceUrl { get; set; } = string.Empty;
[JsonPropertyName("lua_url")]
public string LuaUrl { get; set; } = string.Empty;
[JsonPropertyName("ifix_url")]
public string IfixUrl { get; set; } = string.Empty;
// an empty ex_resource_url hangs the client at ~99% before it ever reaches kcp
public bool IsUsable => AssetBundleUrl.Length > 0 && ExResourceUrl.Length > 0;
}
public sealed class HotfixCache
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private readonly HotfixConfig _config;
private readonly ILogger<HotfixCache> _logger;
private readonly HttpClient _http;
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly string _path;
2026-09-07 21:57:28 -07:00
// versions already told about, so a borrowed hotfix is reported once and not per request
private readonly HashSet<string> _borrowed = new(StringComparer.OrdinalIgnoreCase);
2026-09-07 21:28:11 -07:00
private Dictionary<string, VersionHotfix> _versions = new(StringComparer.OrdinalIgnoreCase);
public HotfixCache(HotfixConfig config, ILogger<HotfixCache> logger)
{
_config = config;
_logger = logger;
_path = Path.IsPathRooted(config.CacheFile)
? config.CacheFile
: Path.Combine(RepoPaths.Config, config.CacheFile);
_http = new HttpClient(new HttpClientHandler { UseProxy = !config.BypassProxy })
{
Timeout = TimeSpan.FromSeconds(30),
};
_http.DefaultRequestHeaders.UserAgent.ParseAdd("UnityPlayer/2021.3.21f1");
Reload();
}
public IReadOnlyCollection<string> KnownVersions => _versions.Keys;
public void Reload()
{
if (!File.Exists(_path))
{
_versions = new Dictionary<string, VersionHotfix>(StringComparer.OrdinalIgnoreCase);
return;
}
try
{
_versions = JsonSerializer.Deserialize<Dictionary<string, VersionHotfix>>(
File.ReadAllText(_path), JsonOptions)
?? [];
_versions = new Dictionary<string, VersionHotfix>(_versions, StringComparer.OrdinalIgnoreCase);
}
catch (JsonException e)
{
_logger.LogError("{Path} is malformed ({Message}), starting empty", _path, e.Message);
_versions = new Dictionary<string, VersionHotfix>(StringComparer.OrdinalIgnoreCase);
}
}
public async Task<VersionHotfix?> GetAsync(string version, string dispatchSeed)
{
if (_versions.TryGetValue(version, out var cached) && cached.IsUsable)
{
return cached;
}
2026-09-07 21:57:28 -07:00
if (_config.AutoFetch)
2026-09-07 21:28:11 -07:00
{
2026-09-07 21:57:28 -07:00
await _lock.WaitAsync();
2026-09-07 21:28:11 -07:00
2026-09-07 21:57:28 -07:00
try
2026-09-07 21:28:11 -07:00
{
2026-09-07 21:57:28 -07:00
if (_versions.TryGetValue(version, out cached) && cached.IsUsable)
{
return cached;
}
2026-09-07 21:28:11 -07:00
2026-09-07 21:57:28 -07:00
var fetched = await FetchAsync(version, dispatchSeed);
if (fetched is not null)
{
_versions[version] = fetched;
Save();
return fetched;
}
}
finally
2026-09-07 21:28:11 -07:00
{
2026-09-07 21:57:28 -07:00
_lock.Release();
2026-09-07 21:28:11 -07:00
}
2026-09-07 21:57:28 -07:00
}
2026-09-07 21:28:11 -07:00
2026-09-07 21:57:28 -07:00
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))
2026-09-07 21:28:11 -07:00
{
2026-09-07 21:57:28 -07:00
return null;
2026-09-07 21:28:11 -07:00
}
2026-09-07 21:57:28 -07:00
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];
2026-09-07 21:28:11 -07:00
}
private async Task<VersionHotfix?> FetchAsync(string version, string dispatchSeed)
{
var host = SelectHost(version);
if (host is null)
{
_logger.LogWarning("no upstream host known for version {Version}", version);
return null;
}
var url = $"https://{host}/query_gateway?version={version}&platform_type=1&language_type=3" +
$"&dispatch_seed={dispatchSeed}&channel_id=1&sub_channel_id=1&is_need_url=1";
_logger.LogInformation("fetching hotfix for {Version} from {Host}", version, host);
try
{
var body = await _http.GetStringAsync(url);
var gateway = GateServer.Parser.ParseFrom(Convert.FromBase64String(body.Trim()));
var hotfix = new VersionHotfix
{
AssetBundleUrl = gateway.AssetBundleUrl,
ExResourceUrl = gateway.ExResourceUrl,
LuaUrl = gateway.LuaUrl,
IfixUrl = gateway.IfixUrl,
};
if (!hotfix.IsUsable)
{
_logger.LogWarning(
"upstream returned no urls for {Version} (retcode {Retcode}, msg '{Msg}')",
version, gateway.Retcode, gateway.StopDesc);
return null;
}
_logger.LogInformation("got hotfix for {Version}", version);
return hotfix;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or FormatException
or InvalidProtocolBufferException)
{
_logger.LogError("hotfix fetch for {Version} failed: {Message}", version, e.Message);
return null;
}
}
private void Save()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
File.WriteAllText(_path, JsonSerializer.Serialize(_versions, JsonOptions));
}
catch (IOException e)
{
_logger.LogError("could not write {Path}: {Message}", _path, e.Message);
}
}
private static string? SelectHost(string version) => version switch
{
_ when version.StartsWith("OSBETA", StringComparison.Ordinal) => "beta-release01-asia.starrails.com",
_ when version.StartsWith("OSPROD", StringComparison.Ordinal) => "prod-official-asia-dp01.starrails.com",
_ when version.StartsWith("CNBETA", StringComparison.Ordinal) => "beta-release01-cn.bhsr.com",
_ when version.StartsWith("CNPROD", StringComparison.Ordinal) => "prod-gf-cn-dp01.bhsr.com",
_ => null,
};
}