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 _logger; private readonly HttpClient _http; 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) { _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 KnownVersions => _versions.Keys; public void Reload() { if (!File.Exists(_path)) { _versions = new Dictionary(StringComparer.OrdinalIgnoreCase); return; } try { _versions = JsonSerializer.Deserialize>( File.ReadAllText(_path), JsonOptions) ?? []; _versions = new Dictionary(_versions, StringComparer.OrdinalIgnoreCase); } catch (JsonException e) { _logger.LogError("{Path} is malformed ({Message}), starting empty", _path, e.Message); _versions = new Dictionary(StringComparer.OrdinalIgnoreCase); } } public async Task GetAsync(string version, string dispatchSeed) { if (_versions.TryGetValue(version, out var cached) && cached.IsUsable) { return cached; } if (_config.AutoFetch) { await _lock.WaitAsync(); try { if (_versions.TryGetValue(version, out cached) && cached.IsUsable) { return cached; } var fetched = await FetchAsync(version, dispatchSeed); 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 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, }; }