using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using HylandHelper.BridgeContracts; using HylandHelper.BridgeMod; using MelonLoader; using MelonLoader.Utils; using Microsoft.CodeAnalysis; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: InternalsVisibleTo("HylandHelper.BridgeMod.Tests")] [assembly: MelonInfo(typeof(HylandHelperBridgeMod), "Hyland Helper Bridge", "0.1.3", "MadJag Studios", null)] [assembly: TargetFramework(".NETCoreApp,Version=v6.0", FrameworkDisplayName = ".NET 6.0")] [assembly: AssemblyCompany("MadJag Studios")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyCopyright("Copyright © 2026 MadJag Studios")] [assembly: AssemblyDescription("Hyland Helper bridge for Schedule I")] [assembly: AssemblyFileVersion("0.1.3.0")] [assembly: AssemblyInformationalVersion("0.1.3")] [assembly: AssemblyProduct("Hyland Helper")] [assembly: AssemblyTitle("HylandHelper.BridgeMod")] [assembly: AssemblyVersion("0.1.3.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Parameter | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter, AllowMultiple = false, Inherited = false)] internal sealed class NullableAttribute : Attribute { public readonly byte[] NullableFlags; public NullableAttribute(byte P_0) { NullableFlags = new byte[1] { P_0 }; } public NullableAttribute(byte[] P_0) { NullableFlags = P_0; } } [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Delegate, AllowMultiple = false, Inherited = false)] internal sealed class NullableContextAttribute : Attribute { public readonly byte Flag; public NullableContextAttribute(byte P_0) { Flag = P_0; } } [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace HylandHelper.BridgeContracts { public static class BridgeContract { public const string SchemaVersion = "1.0.0"; } public enum BridgeEnvelopeType { Heartbeat, Snapshot, Capabilities, SessionEvent, Ingest } public enum BridgeCapabilityStatus { Present, Partial, Unavailable, Degraded } public sealed class BridgeEnvelope { [JsonPropertyName("envelopeType")] public string EnvelopeType { get; set; } = "heartbeat"; [JsonPropertyName("schemaVersion")] public string SchemaVersion { get; set; } = "1.0.0"; [JsonPropertyName("modVersion")] public string ModVersion { get; set; } = ""; [JsonPropertyName("gameVersion")] public string GameVersion { get; set; } = ""; [JsonPropertyName("melonLoaderVersion")] public string MelonLoaderVersion { get; set; } = ""; [JsonPropertyName("saveFingerprint")] public BridgeSaveFingerprint SaveFingerprint { get; set; } = new BridgeSaveFingerprint(); [JsonPropertyName("sequence")] public long Sequence { get; set; } [JsonPropertyName("sentAt")] public string SentAt { get; set; } = ""; [JsonPropertyName("capabilities")] public BridgeCapabilities Capabilities { get; set; } = new BridgeCapabilities(); [JsonPropertyName("snapshot")] public BridgeSnapshot? Snapshot { get; set; } [JsonPropertyName("session")] public BridgeSessionEvent? Session { get; set; } } public sealed class BridgeSaveFingerprint { [JsonPropertyName("slotId")] public string SlotId { get; set; } = ""; [JsonPropertyName("steamIdHash")] public string? SteamIdHash { get; set; } [JsonPropertyName("fingerprint")] public string Fingerprint { get; set; } = ""; } public sealed class BridgeCapabilities { [JsonPropertyName("slices")] public Dictionary Slices { get; set; } = new Dictionary(); [JsonPropertyName("features")] public Dictionary? Features { get; set; } } public sealed class BridgeFeatureCapability { [JsonPropertyName("supported")] public bool Supported { get; set; } [JsonPropertyName("protocolVersion")] public int ProtocolVersion { get; set; } } public sealed class BridgeCapabilitySlice { [JsonPropertyName("status")] public string Status { get; set; } = "unavailable"; [JsonPropertyName("source")] public string Source { get; set; } = "notSupported"; [JsonPropertyName("reason")] public string? Reason { get; set; } [JsonPropertyName("gameDataType")] public string? GameDataType { get; set; } [JsonPropertyName("gameDataVersion")] public int? GameDataVersion { get; set; } } public sealed class BridgeSnapshot { [JsonPropertyName("capturedAt")] public string CapturedAt { get; set; } = ""; [JsonPropertyName("status")] public string Status { get; set; } = "partial"; [JsonPropertyName("slices")] public List Slices { get; set; } = new List(); [JsonPropertyName("money")] public BridgeMoneySnapshot? Money { get; set; } [JsonPropertyName("rank")] public BridgeRankSnapshot? Rank { get; set; } [JsonPropertyName("time")] public BridgeTimeSnapshot? Time { get; set; } [JsonPropertyName("inventory")] public BridgeInventorySnapshot? Inventory { get; set; } [JsonPropertyName("properties")] public BridgePropertiesSnapshot? Properties { get; set; } [JsonPropertyName("products")] public BridgeProductsSnapshot? Products { get; set; } [JsonPropertyName("layout")] public BridgeLayoutSnapshot? Layout { get; set; } [JsonPropertyName("warnings")] public List Warnings { get; set; } = new List(); } public sealed class BridgeMoneySnapshot { [JsonPropertyName("onlineBalance")] public decimal OnlineBalance { get; set; } [JsonPropertyName("networth")] public decimal Networth { get; set; } [JsonPropertyName("lifetimeEarnings")] public decimal LifetimeEarnings { get; set; } [JsonPropertyName("cashOnHandTotal")] public decimal CashOnHandTotal { get; set; } [JsonPropertyName("appProfileCash")] public decimal AppProfileCash { get; set; } [JsonPropertyName("appProfileCashSource")] public string AppProfileCashSource { get; set; } = "cashOnHandTotal"; [JsonPropertyName("cashAggregation")] public BridgeCashAggregation? CashAggregation { get; set; } } public sealed class BridgeCashAggregation { [JsonPropertyName("includesPlayerInventory")] public bool IncludesPlayerInventory { get; set; } [JsonPropertyName("includesStorage")] public bool IncludesStorage { get; set; } [JsonPropertyName("missingContainers")] public List MissingContainers { get; set; } = new List(); } public sealed class BridgeRankSnapshot { [JsonPropertyName("rank")] public string? Rank { get; set; } [JsonPropertyName("tier")] public int? Tier { get; set; } [JsonPropertyName("xp")] public decimal? Xp { get; set; } [JsonPropertyName("totalXp")] public decimal? TotalXp { get; set; } [JsonPropertyName("unlockedRegions")] public List UnlockedRegions { get; set; } = new List(); } public sealed class BridgeTimeSnapshot { [JsonPropertyName("timeOfDay")] public decimal? TimeOfDay { get; set; } [JsonPropertyName("elapsedDays")] public int? ElapsedDays { get; set; } [JsonPropertyName("playtimeSeconds")] public decimal? PlaytimeSeconds { get; set; } } public sealed class BridgeInventorySnapshot { [JsonPropertyName("totalStacks")] public int TotalStacks { get; set; } [JsonPropertyName("totalQuantity")] public decimal TotalQuantity { get; set; } [JsonPropertyName("cashOnHandTotal")] public decimal CashOnHandTotal { get; set; } [JsonPropertyName("sources")] public List Sources { get; set; } = new List(); [JsonPropertyName("items")] public List Items { get; set; } = new List(); [JsonPropertyName("truncated")] public bool Truncated { get; set; } } public sealed class BridgeInventorySourceSummary { [JsonPropertyName("source")] public string Source { get; set; } = ""; [JsonPropertyName("container")] public string? Container { get; set; } [JsonPropertyName("itemStacks")] public int ItemStacks { get; set; } [JsonPropertyName("cashOnHand")] public decimal CashOnHand { get; set; } } public sealed class BridgeInventoryItemStack { [JsonPropertyName("id")] public string Id { get; set; } = ""; [JsonPropertyName("quantity")] public decimal Quantity { get; set; } [JsonPropertyName("source")] public string Source { get; set; } = ""; [JsonPropertyName("container")] public string? Container { get; set; } [JsonPropertyName("dataType")] public string? DataType { get; set; } [JsonPropertyName("quality")] public string? Quality { get; set; } [JsonPropertyName("packagingId")] public string? PackagingId { get; set; } } public sealed class BridgePropertiesSnapshot { [JsonPropertyName("ownedCount")] public int OwnedCount { get; set; } [JsonPropertyName("totalCount")] public int TotalCount { get; set; } [JsonPropertyName("properties")] public List Properties { get; set; } = new List(); } public sealed class BridgePropertySummary { [JsonPropertyName("code")] public string Code { get; set; } = ""; [JsonPropertyName("kind")] public string Kind { get; set; } = ""; [JsonPropertyName("isOwned")] public bool IsOwned { get; set; } [JsonPropertyName("objectCount")] public int ObjectCount { get; set; } [JsonPropertyName("employeeCount")] public int EmployeeCount { get; set; } [JsonPropertyName("launderingOperationCount")] public int LaunderingOperationCount { get; set; } } public sealed class BridgeLayoutSnapshot { [JsonPropertyName("properties")] public List Properties { get; set; } = new List(); [JsonPropertyName("truncated")] public bool Truncated { get; set; } } public sealed class BridgeLayoutProperty { [JsonPropertyName("propertyCode")] public string PropertyCode { get; set; } = ""; [JsonPropertyName("objects")] public List Objects { get; set; } = new List(); [JsonPropertyName("truncated")] public bool Truncated { get; set; } } public sealed class BridgeLayoutObject { [JsonPropertyName("itemId")] public string? ItemId { get; set; } [JsonPropertyName("gridGuid")] public string? GridGuid { get; set; } [JsonPropertyName("x")] public int? X { get; set; } [JsonPropertyName("y")] public int? Y { get; set; } [JsonPropertyName("rotation")] public int? Rotation { get; set; } [JsonPropertyName("dataType")] public string DataType { get; set; } = ""; } public sealed class BridgeProductsSnapshot { [JsonPropertyName("discoveredCount")] public int DiscoveredCount { get; set; } [JsonPropertyName("discoveredProducts")] public List DiscoveredProducts { get; set; } = new List(); [JsonPropertyName("listedProducts")] public List ListedProducts { get; set; } = new List(); [JsonPropertyName("activeMixOperation")] public BridgeActiveMixOperation? ActiveMixOperation { get; set; } [JsonPropertyName("isMixComplete")] public bool? IsMixComplete { get; set; } [JsonPropertyName("recipeCount")] public int RecipeCount { get; set; } [JsonPropertyName("recipes")] public List Recipes { get; set; } = new List(); [JsonPropertyName("truncated")] public bool Truncated { get; set; } } public sealed class BridgeActiveMixOperation { [JsonPropertyName("productId")] public string? ProductId { get; set; } [JsonPropertyName("ingredientId")] public string? IngredientId { get; set; } } public sealed class BridgeMixRecipeSummary { [JsonPropertyName("product")] public string Product { get; set; } = ""; [JsonPropertyName("mixer")] public string Mixer { get; set; } = ""; [JsonPropertyName("output")] public string Output { get; set; } = ""; } public sealed class BridgeSessionEvent { [JsonPropertyName("eventType")] public string EventType { get; set; } = "heartbeat"; [JsonPropertyName("reason")] public string? Reason { get; set; } } public sealed class BridgePendingMix { [JsonPropertyName("mixId")] public string? MixId { get; set; } [JsonPropertyName("productId")] public string ProductId { get; set; } = ""; [JsonPropertyName("ingredientIds")] public List IngredientIds { get; set; } = new List(); [JsonPropertyName("name")] public string? Name { get; set; } [JsonPropertyName("capturedAt")] public string CapturedAt { get; set; } = ""; [JsonPropertyName("source")] public string Source { get; set; } = "inGameTile"; [JsonPropertyName("steps")] public List? Steps { get; set; } } public sealed class BridgePendingMixStep { [JsonPropertyName("ingredientId")] public string IngredientId { get; set; } = ""; [JsonPropertyName("outputProductId")] public string? OutputProductId { get; set; } [JsonPropertyName("outputEffects")] public List? OutputEffects { get; set; } } public static class PendingMixLimits { public const int MaxChainSteps = 64; public const int MaxEffectsPerStep = 12; public const int MaxStringLength = 64; public static string OverLimitReason(int stepCount) { return $"Save/Track unavailable: this mix has {stepCount} steps; the shared limit is {64}."; } } public static class PhoneRecipeLimits { public const int MaxRecipes = 50; public const int MaxChainSteps = 64; public const int MaxEffects = 8; public const int MaxStringLength = 64; public const int MaxBodyBytes = 73728; public const int StaleGraceHours = 24; } public sealed class BridgePhoneRecipe { [JsonPropertyName("recipeId")] public string RecipeId { get; set; } = ""; [JsonPropertyName("name")] public string Name { get; set; } = ""; [JsonPropertyName("productId")] public string ProductId { get; set; } = ""; [JsonPropertyName("ingredientIds")] public List IngredientIds { get; set; } = new List(); [JsonPropertyName("effects")] public List? Effects { get; set; } [JsonPropertyName("effectsAvailable")] public bool EffectsAvailable { get; set; } [JsonPropertyName("sellPrice")] public double SellPrice { get; set; } [JsonPropertyName("profit")] public double Profit { get; set; } [JsonPropertyName("profitMargin")] public double ProfitMargin { get; set; } [JsonPropertyName("profileMode")] public string ProfileMode { get; set; } = "standard"; [JsonPropertyName("source")] public string Source { get; set; } = "app-saved"; [JsonPropertyName("publishedAt")] public string PublishedAt { get; set; } = ""; [JsonPropertyName("expiresAt")] public string ExpiresAt { get; set; } = ""; } public sealed class BridgePhoneRecipeSnapshot { [JsonPropertyName("schemaVersion")] public int SchemaVersion { get; set; } = 1; [JsonPropertyName("snapshotId")] public string SnapshotId { get; set; } = ""; [JsonPropertyName("publishedAt")] public string PublishedAt { get; set; } = ""; [JsonPropertyName("expiresAt")] public string ExpiresAt { get; set; } = ""; [JsonPropertyName("omittedCount")] public int OmittedCount { get; set; } [JsonPropertyName("recipes")] public List Recipes { get; set; } = new List(); } public sealed class BridgeExchangeRequest { [JsonPropertyName("envelope")] public BridgeEnvelope Envelope { get; set; } = new BridgeEnvelope(); [JsonPropertyName("downlink")] public BridgeExchangeRequestDownlink Downlink { get; set; } = new BridgeExchangeRequestDownlink(); } public sealed class BridgeExchangeRequestDownlink { [JsonPropertyName("phoneRecipes")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] public string? PhoneRecipes { get; set; } [JsonPropertyName("advisorDispatches")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] public string? AdvisorDispatches { get; set; } } public sealed class BridgeExchangeResponse { [JsonPropertyName("ingest")] public BridgeIngestAcknowledgement Ingest { get; set; } = new BridgeIngestAcknowledgement(); [JsonPropertyName("downlink")] public BridgeExchangeResponseDownlink Downlink { get; set; } = new BridgeExchangeResponseDownlink(); [JsonPropertyName("pollHintSeconds")] public int? PollHintSeconds { get; set; } } public sealed class BridgeIngestAcknowledgement { [JsonPropertyName("stored")] public bool Stored { get; set; } [JsonPropertyName("stale")] public bool Stale { get; set; } [JsonPropertyName("sequence")] public long Sequence { get; set; } [JsonPropertyName("currentSequence")] public long? CurrentSequence { get; set; } [JsonPropertyName("receivedAt")] public string? ReceivedAt { get; set; } [JsonPropertyName("gameVersionChanged")] public bool? GameVersionChanged { get; set; } } public sealed class BridgeExchangeResponseDownlink { [JsonPropertyName("phoneRecipes")] public BridgePhoneRecipeExchangeSlice PhoneRecipes { get; set; } = new BridgePhoneRecipeExchangeSlice(); [JsonPropertyName("advisorDispatches")] public BridgeAdvisorDispatchExchangeSlice AdvisorDispatches { get; set; } = new BridgeAdvisorDispatchExchangeSlice(); } public sealed class BridgePhoneRecipeExchangeSlice { [JsonPropertyName("state")] public string State { get; set; } = "unsupported"; [JsonPropertyName("revision")] public string? Revision { get; set; } [JsonPropertyName("payload")] public BridgePhoneRecipeSnapshot? Payload { get; set; } [JsonPropertyName("retryAfterSeconds")] public int? RetryAfterSeconds { get; set; } } public sealed class BridgeAdvisorDispatchExchangeSlice { [JsonPropertyName("state")] public string State { get; set; } = "unsupported"; [JsonPropertyName("revision")] public string? Revision { get; set; } [JsonPropertyName("payload")] public BridgeAdvisorDispatchPayload? Payload { get; set; } [JsonPropertyName("retryAfterSeconds")] public int? RetryAfterSeconds { get; set; } } public sealed class BridgeAdvisorDispatchPayload { [JsonPropertyName("scopeGeneration")] public string ScopeGeneration { get; set; } = ""; [JsonPropertyName("capturedAt")] public string CapturedAt { get; set; } = ""; [JsonPropertyName("dispatches")] public List Dispatches { get; set; } = new List(); } public sealed class BridgeAdvisorDispatchRecord { [JsonPropertyName("id")] public string Id { get; set; } = ""; [JsonPropertyName("createdAt")] public string CreatedAt { get; set; } = ""; [JsonPropertyName("headline")] public string Headline { get; set; } = ""; [JsonPropertyName("body")] public string Body { get; set; } = ""; [JsonPropertyName("personaId")] public string PersonaId { get; set; } = ""; [JsonPropertyName("voiceVersion")] public string VoiceVersion { get; set; } = ""; [JsonPropertyName("triggerId")] public string TriggerId { get; set; } = ""; } public sealed class BridgeExchangeError { [JsonPropertyName("error")] public BridgeStructuredError Error { get; set; } = new BridgeStructuredError(); } public sealed class BridgeStructuredError { [JsonPropertyName("code")] public string Code { get; set; } = ""; [JsonPropertyName("message")] public string Message { get; set; } = ""; [JsonPropertyName("retriable")] public bool Retriable { get; set; } [JsonPropertyName("requiresRepair")] public string? RequiresRepair { get; set; } [JsonPropertyName("retryAfterSeconds")] public int? RetryAfterSeconds { get; set; } } public sealed class BridgeTileChallenge { [JsonPropertyName("version")] public int Version { get; set; } [JsonPropertyName("challenge")] public string Challenge { get; set; } = ""; [JsonPropertyName("tileBuild")] public string TileBuild { get; set; } = ""; [JsonPropertyName("protocolVersion")] public int ProtocolVersion { get; set; } } public static class GameDatasetIds { private static readonly Dictionary CanonicalAliases = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["ogkush"] = "og_kush", ["sourdiesel"] = "sour_diesel", ["greencrack"] = "green_crack", ["granddaddypurple"] = "granddaddy_purple", ["shroom"] = "shrooms", ["energydrink"] = "energy_drink", ["motoroil"] = "motor_oil", ["megabean"] = "mega_bean", ["flumedicine"] = "flu_medicine", ["horsesemen"] = "horse_semen", ["viagor"] = "viagra" }; private static readonly Dictionary FriendlyLabels = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["og_kush"] = "OG Kush", ["sour_diesel"] = "Sour Diesel", ["green_crack"] = "Green Crack", ["granddaddy_purple"] = "Granddaddy Purple", ["shrooms"] = "Shrooms", ["meth"] = "Meth", ["cocaine"] = "Cocaine", ["flu_medicine"] = "Flu Medicine", ["energy_drink"] = "Energy Drink", ["motor_oil"] = "Motor Oil", ["mega_bean"] = "Mega Bean", ["horse_semen"] = "Horse Semen", ["viagra"] = "Viagor" }; private static readonly HashSet BaseProducts = new HashSet(StringComparer.OrdinalIgnoreCase) { "og_kush", "sour_diesel", "green_crack", "granddaddy_purple", "meth", "cocaine", "shrooms" }; public static string Canonicalize(string value) { if (string.IsNullOrWhiteSpace(value)) { return value; } string text = value.Trim(); if (!CanonicalAliases.TryGetValue(text, out string value2)) { return text; } return value2; } public static bool IsBaseProduct(string value) { return BaseProducts.Contains(Canonicalize(value)); } public static string FriendlyName(string value) { string text = Canonicalize(value); if (FriendlyLabels.TryGetValue(text, out string value2)) { return value2; } string[] array = text.Replace('_', ' ').Split(' ', StringSplitOptions.RemoveEmptyEntries); for (int i = 0; i < array.Length; i++) { if (array[i].Length != 0) { array[i] = char.ToUpperInvariant(array[i][0]) + array[i].Substring(1); } } return string.Join(" ", array); } } } namespace HylandHelper.BridgeMod { internal enum AdvisorDispatchCacheState { Empty, Fresh, Expired } internal sealed class AdvisorDispatchCacheRead { public AdvisorDispatchCacheState State { get; init; } public string? ScopeGeneration { get; init; } public string? Revision { get; init; } public DateTimeOffset? CapturedAt { get; init; } public List Dispatches { get; init; } = new List(); } internal sealed record AdvisorDispatchCacheCheckpoint(bool Exists, byte[]? Bytes); internal sealed class AdvisorDispatchCache { private sealed class AdvisorDispatchCacheDocument { [JsonPropertyName("version")] public int Version { get; set; } [JsonPropertyName("saveFingerprint")] public string SaveFingerprint { get; set; } = ""; [JsonPropertyName("scopeGeneration")] public string ScopeGeneration { get; set; } = ""; [JsonPropertyName("revision")] public string Revision { get; set; } = ""; [JsonPropertyName("capturedAt")] public string CapturedAt { get; set; } = ""; [JsonPropertyName("dispatches")] public List Dispatches { get; set; } = new List(); } public static readonly TimeSpan DispatchTtl = TimeSpan.FromDays(7.0); private const int MaxCacheBytes = 65536; private const int MaxDispatches = 20; private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, WriteIndented = true }; private readonly string _path; private readonly Action _replaceFile; private readonly Action _deleteFile; public static string DefaultPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "TVGS", "Schedule I", "HylandHelper", "advisor-dispatches-cache.json"); public AdvisorDispatchCache(string path, Action? replaceFile = null, Action? deleteFile = null) { _path = path; _replaceFile = replaceFile ?? ((Action)delegate(string source, string destination) { File.Move(source, destination, overwrite: true); }); _deleteFile = deleteFile ?? new Action(File.Delete); } public bool Write(BridgeAdvisorDispatchPayload payload, string revision, DateTimeOffset now, string saveFingerprint) { if (!SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint) || !CanWrite(payload, revision, now, out var capturedAt)) { return false; } AdvisorDispatchCacheDocument value = new AdvisorDispatchCacheDocument { Version = 2, SaveFingerprint = saveFingerprint, ScopeGeneration = payload.ScopeGeneration, Revision = revision, CapturedAt = capturedAt.ToString("O"), Dispatches = payload.Dispatches }; return WriteRawAtomic(JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions)); } internal static bool CanWrite(BridgeAdvisorDispatchPayload payload, string revision, DateTimeOffset now) { DateTimeOffset capturedAt; return CanWrite(payload, revision, now, out capturedAt); } private static bool CanWrite(BridgeAdvisorDispatchPayload payload, string revision, DateTimeOffset now, out DateTimeOffset capturedAt) { if (!IsSafePayload(payload, revision, out capturedAt)) { return false; } DateTimeOffset result; return payload.Dispatches.All((BridgeAdvisorDispatchRecord record) => DateTimeOffset.TryParse(record.CreatedAt, out result) && now < result + DispatchTtl); } public AdvisorDispatchCacheRead Read(DateTimeOffset now, string saveFingerprint) { try { if (!SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint) || !TryReadDocument(out AdvisorDispatchCacheDocument document) || !string.Equals(document.SaveFingerprint, saveFingerprint, StringComparison.Ordinal)) { return Empty(); } BridgeAdvisorDispatchPayload bridgeAdvisorDispatchPayload = new BridgeAdvisorDispatchPayload { ScopeGeneration = document.ScopeGeneration, CapturedAt = document.CapturedAt, Dispatches = (document.Dispatches ?? new List()) }; if (!IsSafePayload(bridgeAdvisorDispatchPayload, document.Revision, out var capturedAt)) { return Empty(); } DateTimeOffset result; List list = bridgeAdvisorDispatchPayload.Dispatches.Where((BridgeAdvisorDispatchRecord record) => DateTimeOffset.TryParse(record.CreatedAt, out result) && now < result + DispatchTtl).ToList(); return new AdvisorDispatchCacheRead { State = ((bridgeAdvisorDispatchPayload.Dispatches.Count <= 0 || list.Count != 0) ? AdvisorDispatchCacheState.Fresh : AdvisorDispatchCacheState.Expired), ScopeGeneration = bridgeAdvisorDispatchPayload.ScopeGeneration, Revision = document.Revision, CapturedAt = capturedAt, Dispatches = list }; } catch { return Empty(); } } public bool Clear() { try { if (File.Exists(_path)) { _deleteFile(_path); } return !File.Exists(_path); } catch { return false; } } public bool ClearForScope(string saveFingerprint) { try { if (!SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint)) { return false; } if (!File.Exists(_path)) { return true; } if (!TryReadDocument(out AdvisorDispatchCacheDocument document) || !string.Equals(document.SaveFingerprint, saveFingerprint, StringComparison.Ordinal)) { return true; } return Clear(); } catch { return false; } } internal bool TryCapture(out AdvisorDispatchCacheCheckpoint checkpoint) { checkpoint = new AdvisorDispatchCacheCheckpoint(Exists: false, null); try { if (!File.Exists(_path)) { return true; } if (!IsBoundedFile()) { return false; } checkpoint = new AdvisorDispatchCacheCheckpoint(Exists: true, File.ReadAllBytes(_path)); return true; } catch { return false; } } internal bool Restore(AdvisorDispatchCacheCheckpoint checkpoint) { if (!checkpoint.Exists || checkpoint.Bytes == null) { return Clear(); } return WriteRawAtomic(checkpoint.Bytes); } private static AdvisorDispatchCacheRead Empty() { return new AdvisorDispatchCacheRead { State = AdvisorDispatchCacheState.Empty }; } private static bool IsSafePayload(BridgeAdvisorDispatchPayload? payload, string? revision, out DateTimeOffset capturedAt) { capturedAt = default(DateTimeOffset); if (payload == null || !Bounded(payload.ScopeGeneration, 128) || !Bounded(revision, 128) || !DateTimeOffset.TryParse(payload.CapturedAt, out capturedAt) || payload.Dispatches == null || payload.Dispatches.Count > 20) { return false; } HashSet hashSet = new HashSet(StringComparer.Ordinal); foreach (BridgeAdvisorDispatchRecord dispatch in payload.Dispatches) { if (dispatch == null || !Bounded(dispatch.Id, 64) || !hashSet.Add(dispatch.Id) || !DateTimeOffset.TryParse(dispatch.CreatedAt, out var result) || result > capturedAt || capturedAt - result > DispatchTtl || !Bounded(dispatch.Headline, 60) || !Bounded(dispatch.Body, 512) || !Bounded(dispatch.PersonaId, 64) || !Bounded(dispatch.VoiceVersion, 64) || !Bounded(dispatch.TriggerId, 64)) { return false; } } return true; } private bool WriteRawAtomic(byte[] bytes) { if (bytes.Length == 0 || bytes.Length > 65536) { return false; } if (ExistingBytesEqual(bytes)) { return true; } string text = null; try { string directoryName = Path.GetDirectoryName(_path); if (!string.IsNullOrWhiteSpace(directoryName)) { Directory.CreateDirectory(directoryName); } text = $"{_path}.{Guid.NewGuid():N}.tmp"; using (FileStream fileStream = new FileStream(text, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { fileStream.Write(bytes, 0, bytes.Length); fileStream.Flush(flushToDisk: true); } _replaceFile(text, _path); text = null; return true; } catch { return false; } finally { if (text != null) { try { File.Delete(text); } catch { } } } } private bool ExistingBytesEqual(byte[] desired) { try { FileInfo fileInfo = new FileInfo(_path); return fileInfo.Exists && fileInfo.Length == desired.Length && File.ReadAllBytes(_path).SequenceEqual(desired); } catch { return false; } } private bool IsBoundedFile() { if (!File.Exists(_path)) { return false; } long length = new FileInfo(_path).Length; if (length > 0) { return length <= 65536; } return false; } private bool TryReadDocument(out AdvisorDispatchCacheDocument document) { document = null; if (!IsBoundedFile()) { return false; } byte[] array = File.ReadAllBytes(_path); using JsonDocument jsonDocument = JsonDocument.Parse(array); if (!HasExactProperties(jsonDocument.RootElement, "version", "saveFingerprint", "scopeGeneration", "revision", "capturedAt", "dispatches")) { return false; } AdvisorDispatchCacheDocument advisorDispatchCacheDocument = JsonSerializer.Deserialize(array, JsonOptions); if (advisorDispatchCacheDocument == null || advisorDispatchCacheDocument.Version != 2 || !SaveGenerationIdentity.IsV2Fingerprint(advisorDispatchCacheDocument.SaveFingerprint)) { return false; } document = advisorDispatchCacheDocument; return true; } private static bool HasExactProperties(JsonElement element, params string[] expected) { if (element.ValueKind != JsonValueKind.Object) { return false; } HashSet hashSet = new HashSet(expected, StringComparer.Ordinal); foreach (JsonProperty item in element.EnumerateObject()) { if (!hashSet.Remove(item.Name)) { return false; } } return hashSet.Count == 0; } private static bool Bounded(string? value, int maxLength) { if (!string.IsNullOrWhiteSpace(value)) { return value.Length <= maxLength; } return false; } } internal interface IBridgeCadenceAuthority { bool TryAdmitAdaptiveFull(DateTimeOffset now); bool TryAdmitNonAdaptiveStart(DateTimeOffset now); bool TryAdmitHeartbeat(DateTimeOffset now, out bool adaptive); void NotifyStarted(BridgeEnvelopePlan plan, bool adaptive, DateTimeOffset now); void NotifyCompleted(BridgeEnvelopePlan plan, DateTimeOffset now); } internal sealed class BridgeCadenceController : IBridgeCadenceAuthority { internal static readonly TimeSpan FloorInterval = TimeSpan.FromSeconds(3.0); internal static readonly TimeSpan BurstInterval = TimeSpan.FromSeconds(3.0); internal static readonly TimeSpan BurstMaxDuration = TimeSpan.FromSeconds(30.0); internal static readonly TimeSpan ActiveVisibleInterval = TimeSpan.FromSeconds(7.0); internal static readonly TimeSpan HintLease = TimeSpan.FromSeconds(60.0); internal static readonly TimeSpan RuntimeObservationFloor = TimeSpan.FromSeconds(10.0); internal const int MaxAdaptiveStartsPerWindow = 20; private readonly TimeSpan _idle; private readonly bool _enabled; private DateTimeOffset _lastStart = DateTimeOffset.MinValue; private DateTimeOffset _lastCompleted = DateTimeOffset.MinValue; private DateTimeOffset _burst = DateTimeOffset.MinValue; private DateTimeOffset _lease = DateTimeOffset.MinValue; private DateTimeOffset _lastOverlayFull = DateTimeOffset.MinValue; private bool _visible; private bool _immediate; private readonly Queue _adaptive = new Queue(); private string? _facts; private DateTimeOffset? _armed; internal bool HasPendingImmediate => _immediate; internal BridgeCadenceController(TimeSpan idleInterval, bool accelerationEnabled) { _idle = idleInterval; _enabled = accelerationEnabled; } internal void ObserveScreen(bool visible, bool openedTransition, DateTimeOffset now) { if (_enabled) { _visible = visible; if (openedTransition) { _immediate = true; _burst = now; } } } internal void NotifyHint(int? hint, bool acceptedFreshDelivery, DateTimeOffset now) { if (_enabled && acceptedFreshDelivery && hint.HasValue) { int valueOrDefault = hint.GetValueOrDefault(); if (valueOrDefault >= 1 && valueOrDefault <= 60) { _lease = now + HintLease; } } } public bool TryAdmitAdaptiveFull(DateTimeOffset now) { return Admit(now, adaptive: true); } public bool TryAdmitNonAdaptiveStart(DateTimeOffset now) { return Admit(now, adaptive: false); } public bool TryAdmitHeartbeat(DateTimeOffset now, out bool adaptive) { adaptive = false; if (!_enabled) { return now >= _lastCompleted + _idle; } if (_immediate && Admit(now, adaptive: true)) { adaptive = true; return true; } TimeSpan timeSpan = Interval(now); adaptive = timeSpan < _idle; DateTimeOffset dateTimeOffset = (adaptive ? (_lastStart + timeSpan) : (_lastCompleted + _idle)); if (now >= dateTimeOffset) { return Admit(now, adaptive); } return false; } private TimeSpan Interval(DateTimeOffset now) { if (!_visible || !(now - _burst < BurstMaxDuration)) { if (!_visible && !(now < _lease)) { return _idle; } return ActiveVisibleInterval; } return BurstInterval; } private bool Admit(DateTimeOffset now, bool adaptive) { if (!_enabled) { return true; } if (now - _lastStart < FloorInterval) { return false; } while (_adaptive.Count > 0 && now - _adaptive.Peek() >= TimeSpan.FromMinutes(1.0)) { _adaptive.Dequeue(); } if (adaptive) { return _adaptive.Count < 20; } return true; } public void NotifyStarted(BridgeEnvelopePlan plan, bool adaptive, DateTimeOffset now) { if (plan.Kind == BridgeEnvelopeKind.FullSnapshot) { _armed = null; _lastOverlayFull = now; } if (_enabled) { _lastStart = now; if (adaptive) { _adaptive.Enqueue(now); } _immediate = false; } } public void NotifyCompleted(BridgeEnvelopePlan plan, DateTimeOffset now) { _lastCompleted = now; } internal void OnOverlayAccepted(LiveOverlayReadResult result, DateTimeOffset now) { if (result.Status != LiveOverlayReadStatus.Accepted || result.Document == null) { return; } string text = JsonSerializer.Serialize(result.Document.Facts, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); bool flag = text != _facts; _facts = text; if (result.Rotation || flag) { DateTimeOffset valueOrDefault = _armed.GetValueOrDefault(); if (!_armed.HasValue) { valueOrDefault = now; _armed = valueOrDefault; } } } internal bool ShouldRequestRuntimeObservationFull(DateTimeOffset now) { if (!_armed.HasValue || now - _lastOverlayFull < RuntimeObservationFloor) { return false; } _armed = null; return true; } internal void Reset() { _lastStart = (_lastCompleted = (_burst = (_lease = DateTimeOffset.MinValue))); _visible = (_immediate = false); _adaptive.Clear(); _armed = null; _facts = null; } } internal static class AcceptedFreshDelivery { internal static bool Evaluate(BridgeIngestAcknowledgement acknowledgement, BridgeDownlinkApplyOutcome outcome, string? requestKnownRevision, string? responseRevision) { bool flag = acknowledgement.Stored && !acknowledgement.Stale; if (flag) { bool flag2 = (uint)outcome <= 1u; flag = flag2; } if (flag && !string.IsNullOrWhiteSpace(requestKnownRevision) && !string.IsNullOrWhiteSpace(responseRevision)) { return responseRevision != requestKnownRevision; } return false; } } internal sealed class BridgeClient : IDisposable { public const int DefaultMaxExchangeRequestBytes = 1048576; public const int DefaultMaxExchangeResponseBytes = 262144; private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNameCaseInsensitive = true }; private readonly HttpClient _httpClient; private readonly Uri _baseUri; private readonly MelonBridgeLogger _logger; private readonly int _maxExchangeRequestBytes; private readonly int _maxExchangeResponseBytes; public BridgeClient(string baseUrl, MelonBridgeLogger logger) : this(baseUrl, logger, null) { } internal BridgeClient(string baseUrl, MelonBridgeLogger logger, HttpMessageHandler? handler, int maxExchangeRequestBytes = 1048576, int maxExchangeResponseBytes = 262144) { if (maxExchangeRequestBytes <= 0 || maxExchangeResponseBytes <= 0) { throw new ArgumentOutOfRangeException("maxExchangeRequestBytes"); } _baseUri = new Uri(baseUrl.TrimEnd('/') + "/", UriKind.Absolute); _logger = logger; _maxExchangeRequestBytes = maxExchangeRequestBytes; _maxExchangeResponseBytes = maxExchangeResponseBytes; _httpClient = ((handler == null) ? new HttpClient() : new HttpClient(handler)); _httpClient.Timeout = TimeSpan.FromSeconds(15.0); } public async Task ExchangeAsync(string bridgeToken, BridgeExchangeRequest exchange, CancellationToken cancellationToken) { string text = JsonSerializer.Serialize(exchange, JsonOptions); if (Encoding.UTF8.GetByteCount(text) > _maxExchangeRequestBytes) { throw new BridgePayloadException("Bridge exchange request exceeded the local byte limit."); } using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, new Uri(_baseUri, "api/bridge/exchange")) { Content = new StringContent(text, Encoding.UTF8, "application/json") }; request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bridgeToken); using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); TimeSpan? retryAfter = ReadRetryAfter(response); byte[] array = await ReadBoundedBodyAsync(response, _maxExchangeResponseBytes, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (!response.IsSuccessStatusCode) { throw CreateExchangeException(response.StatusCode, array, retryAfter); } try { return JsonSerializer.Deserialize(array, JsonOptions) ?? throw new BridgePayloadException("Bridge exchange response was empty."); } catch (JsonException innerException) { throw new BridgePayloadException("Bridge exchange response was malformed.", innerException); } } public async Task SendPendingMixAsync(string bridgeToken, BridgePendingMix mix, CancellationToken cancellationToken) { try { return await PostJsonAsync("api/bridge/mixes", mix, bridgeToken, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } catch (BridgeClientException ex) when (ex.IsUnauthorized) { throw; } catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) { _logger.WarningThrottled("Pending mix POST quiet-failed.", exception); return null; } catch (Exception ex2) when (!(ex2 is OperationCanceledException)) { _logger.WarningThrottled("Pending mix POST quiet-failed.", ex2); return null; } } public async Task StartPairingAsync(BridgeSaveFingerprint saveFingerprint, CancellationToken cancellationToken) { PairingStartResponse pairingStartResponse = await PostJsonAsync("api/bridge/pairing/start", new { saveFingerprint }, null, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (string.IsNullOrWhiteSpace(pairingStartResponse.PairingId) || string.IsNullOrWhiteSpace(pairingStartResponse.Code) || string.IsNullOrWhiteSpace(pairingStartResponse.ModSecret)) { throw new InvalidOperationException("Pairing start response was incomplete."); } return new PairingSession(pairingStartResponse.PairingId, pairingStartResponse.Code, pairingStartResponse.ModSecret); } public async Task PollPairingStatusAsync(PairingSession session, CancellationToken cancellationToken) { string path = "api/bridge/pairing/" + Uri.EscapeDataString(session.PairingId) + "/status"; PairingStatusResponse pairingStatusResponse = await PostJsonAsync(path, new { modSecret = session.ModSecret }, null, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (!string.IsNullOrWhiteSpace(pairingStatusResponse.BridgeToken)) { pairingStatusResponse.VerifiedScope = pairingStatusResponse.Scope?.ToPairingScope(session.PairingId) ?? throw new InvalidOperationException("Claimed pairing response omitted its verified scope."); } return pairingStatusResponse; } public Task SendHeartbeatAsync(string bridgeToken, BridgeEnvelope heartbeat, CancellationToken cancellationToken) { return PostJsonAsync("api/bridge/ingest", heartbeat, bridgeToken, cancellationToken); } public async Task GetLatestAsync(string bridgeToken, PairingScope scope, CancellationToken cancellationToken) { string relativeUri = $"api/bridge/latest?pairingId={Uri.EscapeDataString(scope.PairingId)}&deviceId={Uri.EscapeDataString(scope.DeviceId)}&profileId={Uri.EscapeDataString(scope.ProfileId)}"; try { using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, relativeUri)); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bridgeToken); using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (response.StatusCode == HttpStatusCode.Unauthorized) { throw new BridgeClientException(HttpStatusCode.Unauthorized, "Latest poll was unauthorized."); } if (!response.IsSuccessStatusCode) { _logger.WarningThrottled($"Latest poll returned {response.StatusCode}."); return null; } return JsonSerializer.Deserialize(await ReadBoundedBodyAsync(response, _maxExchangeResponseBytes, cancellationToken).ConfigureAwait(continueOnCapturedContext: false), JsonOptions); } catch (BridgeClientException ex) when (ex.IsUnauthorized) { throw; } catch (Exception ex2) when (!(ex2 is OperationCanceledException)) { _logger.WarningThrottled("Latest poll quiet-failed.", ex2); return null; } } public async Task GetPhoneRecipeAvailabilityAsync(string bridgeToken, CancellationToken cancellationToken) { _ = 1; try { using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, "api/bridge/recipes/availability")); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bridgeToken); using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (response.StatusCode == HttpStatusCode.Unauthorized) { throw new BridgeClientException(HttpStatusCode.Unauthorized, "Phone recipe poll was unauthorized."); } if (response.StatusCode == HttpStatusCode.NotFound) { return new PhoneRecipePollResult(null, NotPublished: true); } if (!response.IsSuccessStatusCode) { _logger.WarningThrottled($"Phone recipe poll returned {response.StatusCode}."); return null; } BridgePhoneRecipeSnapshot bridgePhoneRecipeSnapshot = JsonSerializer.Deserialize(await ReadBoundedBodyAsync(response, _maxExchangeResponseBytes, cancellationToken).ConfigureAwait(continueOnCapturedContext: false), JsonOptions); return (bridgePhoneRecipeSnapshot == null) ? null : new PhoneRecipePollResult(bridgePhoneRecipeSnapshot, NotPublished: false); } catch (BridgeClientException ex) when (ex.IsUnauthorized) { throw; } catch (Exception ex2) when (!(ex2 is OperationCanceledException)) { _logger.WarningThrottled("Phone recipe poll quiet-failed.", ex2); return null; } } public void Dispose() { _httpClient.Dispose(); } private async Task PostJsonAsync(string path, object body, string? bearerToken, CancellationToken cancellationToken) { string content = JsonSerializer.Serialize(body, JsonOptions); using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, new Uri(_baseUri, path)) { Content = new StringContent(content, Encoding.UTF8, "application/json") }; if (!string.IsNullOrWhiteSpace(bearerToken)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); } using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (!response.IsSuccessStatusCode) { throw new BridgeClientException(response.StatusCode, $"{path} returned HTTP {response.StatusCode}."); } if (typeof(T) == typeof(object)) { return (T)new object(); } return JsonSerializer.Deserialize(await ReadBoundedBodyAsync(response, _maxExchangeResponseBytes, cancellationToken).ConfigureAwait(continueOnCapturedContext: false), JsonOptions) ?? throw new InvalidOperationException(path + " returned an empty response."); } private static BridgeClientException CreateExchangeException(HttpStatusCode statusCode, byte[] body, TimeSpan? retryAfter) { BridgeStructuredError bridgeStructuredError = null; try { BridgeExchangeError bridgeExchangeError = JsonSerializer.Deserialize(body, JsonOptions); if (bridgeExchangeError?.Error != null && IsSafeErrorCode(bridgeExchangeError.Error.Code)) { bridgeStructuredError = bridgeExchangeError.Error; } } catch (JsonException) { } string text = bridgeStructuredError?.Code; string requiresRepair = (IsBounded(bridgeStructuredError?.RequiresRepair, 64) ? bridgeStructuredError.RequiresRepair : null); int? num = bridgeStructuredError?.RetryAfterSeconds; TimeSpan? obj; if (num.HasValue) { int valueOrDefault = num.GetValueOrDefault(); if (valueOrDefault >= 0 && valueOrDefault <= 86400) { obj = TimeSpan.FromSeconds(bridgeStructuredError.RetryAfterSeconds.Value); goto IL_00cd; } } obj = null; goto IL_00cd; IL_00cd: TimeSpan? timeSpan = obj; string value = ((text == null) ? "HTTP_ERROR" : text); return new BridgeClientException(statusCode, $"Bridge exchange failed with HTTP {statusCode} ({value}).", text, bridgeStructuredError?.Retriable ?? false, requiresRepair, retryAfter ?? timeSpan); } private static bool IsSafeErrorCode(string? code) { switch (code) { case "SAVE_SCOPE_MISMATCH": case "BRIDGE_TOKEN_INVALID": case "PAIRING_SCOPE_MISMATCH": case "ENTITLEMENT_PROOF_REQUIRED": case "RATE_LIMITED": return true; default: return false; } } private static bool IsBounded(string? value, int maxLength) { if (!string.IsNullOrWhiteSpace(value)) { return value.Length <= maxLength; } return false; } private static TimeSpan? ReadRetryAfter(HttpResponseMessage response) { RetryConditionHeaderValue retryAfter = response.Headers.RetryAfter; TimeSpan? timeSpan = retryAfter?.Delta; if (timeSpan.HasValue) { return timeSpan.GetValueOrDefault(); } DateTimeOffset? dateTimeOffset = retryAfter?.Date; if (dateTimeOffset.HasValue) { DateTimeOffset valueOrDefault = dateTimeOffset.GetValueOrDefault(); TimeSpan timeSpan2 = valueOrDefault - DateTimeOffset.UtcNow; return (timeSpan2 > TimeSpan.Zero) ? timeSpan2 : TimeSpan.Zero; } return null; } private static async Task ReadBoundedBodyAsync(HttpResponseMessage response, int maxBytes, CancellationToken cancellationToken) { long? contentLength = response.Content.Headers.ContentLength; if (contentLength.HasValue && contentLength.GetValueOrDefault() > 0 && response.Content.Headers.ContentLength > maxBytes) { throw new BridgePayloadException("Bridge response exceeded the local byte limit."); } byte[] result; await using (Stream source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false)) { using MemoryStream destination = new MemoryStream(); byte[] buffer = new byte[Math.Min(8192, maxBytes + 1)]; while (true) { int num = await source.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (num == 0) { break; } if (destination.Length + num > maxBytes) { throw new BridgePayloadException("Bridge response exceeded the local byte limit."); } destination.Write(buffer, 0, num); } result = destination.ToArray(); } return result; } } internal sealed class BridgeClientException : Exception { public HttpStatusCode StatusCode { get; } public string? Code { get; } public bool Retriable { get; } public string? RequiresRepair { get; } public TimeSpan? RetryAfter { get; } public bool IsUnauthorized => StatusCode == HttpStatusCode.Unauthorized; public bool IsUnsupported => StatusCode == HttpStatusCode.NotFound; public bool IsTransient { get { if (!Retriable && StatusCode != HttpStatusCode.TooManyRequests) { return StatusCode >= HttpStatusCode.InternalServerError; } return true; } } public BridgeClientException(HttpStatusCode statusCode, string message, string? code = null, bool retriable = false, string? requiresRepair = null, TimeSpan? retryAfter = null) : base(message) { StatusCode = statusCode; Code = code; Retriable = retriable; RequiresRepair = requiresRepair; RetryAfter = retryAfter; } } internal sealed class BridgePayloadException : Exception { public BridgePayloadException(string message) : base(message) { } public BridgePayloadException(string message, Exception innerException) : base(message, innerException) { } } internal sealed record PhoneRecipePollResult(BridgePhoneRecipeSnapshot? Snapshot, bool NotPublished); internal sealed record PairingSession(string PairingId, string Code, string ModSecret); internal sealed class PairingStartResponse { [JsonPropertyName("pairingId")] public string? PairingId { get; set; } [JsonPropertyName("code")] public string? Code { get; set; } [JsonPropertyName("modSecret")] public string? ModSecret { get; set; } } internal sealed class PairingStatusResponse { [JsonPropertyName("status")] public string Status { get; set; } = ""; [JsonPropertyName("bridgeToken")] public string? BridgeToken { get; set; } [JsonPropertyName("scope")] public PairingScopeResponse? Scope { get; set; } [JsonIgnore] public PairingScope? VerifiedScope { get; set; } } internal sealed class PairingScopeResponse { [JsonPropertyName("deviceId")] public string DeviceId { get; set; } = ""; [JsonPropertyName("profileId")] public string ProfileId { get; set; } = ""; [JsonPropertyName("saveFingerprint")] public BridgeSaveFingerprint? SaveFingerprint { get; set; } public PairingScope ToPairingScope(string pairingId) { PairingScope obj = new PairingScope { PairingId = pairingId, DeviceId = DeviceId, ProfileId = ProfileId, SaveFingerprint = (SaveFingerprint ?? new BridgeSaveFingerprint()) }; if (!obj.IsValid()) { throw new InvalidOperationException("Claimed pairing response contained an invalid verified scope."); } return obj; } } internal sealed class LatestStateResponse { [JsonPropertyName("sequence")] public long? Sequence { get; set; } } internal sealed class PendingMixPostResponse { [JsonPropertyName("accepted")] public bool Accepted { get; set; } [JsonPropertyName("deduped")] public bool Deduped { get; set; } [JsonPropertyName("mixId")] public string? MixId { get; set; } } internal sealed class BridgeCommandReader { private const long MaxCommandBytes = 8192L; private readonly MelonBridgeLogger _logger; private readonly string _path; private static readonly Regex IsoTimestampPattern = new Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?(?:Z|[+-]\\d{2}:\\d{2})$", RegexOptions.CultureInvariant); private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; public BridgeCommandReader(MelonBridgeLogger logger) { _logger = logger; _path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "TVGS", "Schedule I", "HylandHelper", "bridge-command.json"); } public BridgeCommand? TakeCommand() { try { if (!File.Exists(_path)) { return null; } BridgeCommand command = null; try { if (new FileInfo(_path).Length <= 8192) { if (!TryParseCommand(File.ReadAllText(_path), out command)) { command = null; } } else { _logger.WarningThrottled("Bridge command file exceeded the safety limit; discarding."); } } catch (Exception exception) { _logger.WarningThrottled("Bridge command file was unreadable; discarding.", exception); } try { File.Delete(_path); } catch { } return command; } catch (Exception exception2) { _logger.WarningThrottled("Bridge command check failed.", exception2); return null; } } public bool TakeUnlink() { try { if (!File.Exists(_path)) { return false; } string a = null; BridgeCommand command = null; try { if (new FileInfo(_path).Length <= 8192 && TryParseCommand(File.ReadAllText(_path), out command)) { a = command?.Command; } } catch (Exception exception) { _logger.WarningThrottled("Bridge command file was unreadable; discarding.", exception); } if (IsSupported(command) && string.Equals(a, "saveMix", StringComparison.OrdinalIgnoreCase)) { return false; } try { File.Delete(_path); } catch { } return string.Equals(a, "unlink", StringComparison.OrdinalIgnoreCase); } catch (Exception exception2) { _logger.WarningThrottled("Bridge unlink command check failed.", exception2); return false; } } private static bool IsSupported(BridgeCommand? command) { if (command == null || string.IsNullOrWhiteSpace(command.Command)) { return false; } if (string.Equals(command.Command, "unlink", StringComparison.OrdinalIgnoreCase)) { return true; } if (!SaveGenerationIdentity.IsV2Fingerprint(command.SaveFingerprint)) { return false; } if (string.Equals(command.Command, "syncNow", StringComparison.OrdinalIgnoreCase)) { if (command.Version == 1 && IsSafeSyncRequestId(command.RequestId) && IsUtcIsoTimestamp(command.RequestedAt)) { return command.Mix == null; } return false; } if (!string.Equals(command.Command, "saveMix", StringComparison.OrdinalIgnoreCase) || !IsSafeRequestId(command.RequestId) || command.Mix == null) { return false; } BridgePendingMix mix = command.Mix; if (!IsBoundedString(mix.ProductId) || mix.IngredientIds == null || mix.IngredientIds.Count == 0 || mix.IngredientIds.Count > 64 || mix.IngredientIds.Exists((string id) => !IsBoundedString(id)) || (mix.Name != null && mix.Name.Length > 64) || !IsIsoTimestamp(mix.CapturedAt) || !string.Equals(mix.Source, "inGameTile", StringComparison.Ordinal) || mix.Steps == null || mix.Steps.Count != mix.IngredientIds.Count) { return false; } for (int num = 0; num < mix.Steps.Count; num++) { BridgePendingMixStep bridgePendingMixStep = mix.Steps[num]; if (bridgePendingMixStep == null || !IsBoundedString(bridgePendingMixStep.IngredientId) || !string.Equals(bridgePendingMixStep.IngredientId, mix.IngredientIds[num], StringComparison.OrdinalIgnoreCase) || !IsBoundedString(bridgePendingMixStep.OutputProductId) || bridgePendingMixStep.OutputEffects == null || bridgePendingMixStep.OutputEffects.Count > 12 || bridgePendingMixStep.OutputEffects.Exists((string effect) => !IsBoundedString(effect))) { return false; } } return true; } private static bool TryParseCommand(string json, out BridgeCommand? command) { command = null; using JsonDocument jsonDocument = JsonDocument.Parse(json); JsonElement rootElement = jsonDocument.RootElement; if (rootElement.ValueKind != JsonValueKind.Object || !rootElement.TryGetProperty("command", out var value) || value.ValueKind != JsonValueKind.String) { return false; } string[] array = value.GetString()?.ToLowerInvariant() switch { "unlink" => new string[1] { "command" }, "syncnow" => new string[5] { "command", "version", "saveFingerprint", "requestId", "requestedAt" }, "savemix" => new string[4] { "command", "saveFingerprint", "requestId", "mix" }, _ => Array.Empty(), }; if (array.Length == 0 || !HasExactFields(rootElement, (IReadOnlyCollection)(object)array)) { return false; } command = JsonSerializer.Deserialize(json, JsonOptions); return IsSupported(command); } private static bool HasExactFields(JsonElement root, IReadOnlyCollection expected) { HashSet hashSet = new HashSet(expected, StringComparer.Ordinal); foreach (JsonProperty item in root.EnumerateObject()) { if (!hashSet.Remove(item.Name)) { return false; } } return hashSet.Count == 0; } private static bool IsSafeRequestId(string? requestId) { if (string.IsNullOrWhiteSpace(requestId) || requestId.Length > 64) { return false; } foreach (char c in requestId) { if (!char.IsLetterOrDigit(c) && c != '-' && c != '_') { return false; } } return true; } private static bool IsSafeSyncRequestId(string? requestId) { if (requestId == null || requestId.Length != 32) { return false; } for (int i = 0; i < requestId.Length; i++) { if (!char.IsLetterOrDigit(requestId[i])) { return false; } } return true; } private static bool IsBoundedString(string? value) { if (!string.IsNullOrWhiteSpace(value)) { return value.Length <= 64; } return false; } private static bool IsIsoTimestamp(string? value) { DateTimeOffset result; if (!string.IsNullOrWhiteSpace(value) && value.Length >= 20 && value.Length <= 40 && IsoTimestampPattern.IsMatch(value)) { return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out result); } return false; } private static bool IsUtcIsoTimestamp(string? value) { if (IsIsoTimestamp(value)) { return value.EndsWith("Z", StringComparison.Ordinal); } return false; } } internal sealed class BridgeCommand { [JsonPropertyName("command")] public string? Command { get; set; } [JsonPropertyName("version")] public int? Version { get; set; } [JsonPropertyName("saveFingerprint")] public string? SaveFingerprint { get; set; } [JsonPropertyName("requestId")] public string? RequestId { get; set; } [JsonPropertyName("requestedAt")] public string? RequestedAt { get; set; } [JsonPropertyName("mix")] public BridgePendingMix? Mix { get; set; } } internal sealed class BridgeCommandResultWriter { private readonly MelonBridgeLogger _logger; private readonly string _directory; public BridgeCommandResultWriter(MelonBridgeLogger logger) { _logger = logger; _directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "TVGS", "Schedule I", "HylandHelper"); try { Directory.CreateDirectory(_directory); } catch { } } public void Write(string requestId, string status, string saveFingerprint) { if (!IsSafeRequestId(requestId) || string.IsNullOrWhiteSpace(status) || !SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint)) { return; } string text = Path.Combine(_directory, "bridge-command-result-" + requestId + ".json"); string text2 = $"{text}.{Guid.NewGuid():N}.tmp"; try { Directory.CreateDirectory(_directory); File.WriteAllText(text2, JsonSerializer.Serialize(new { version = 1, requestId = requestId, status = status, saveFingerprint = saveFingerprint })); File.Move(text2, text, overwrite: true); } catch (Exception exception) { try { if (File.Exists(text2)) { File.Delete(text2); } } catch { } _logger.WarningThrottled("Bridge command result write failed.", exception); } } private static bool IsSafeRequestId(string requestId) { if (string.IsNullOrWhiteSpace(requestId) || requestId.Length > 64) { return false; } foreach (char c in requestId) { if (!char.IsLetterOrDigit(c) && c != '-' && c != '_') { return false; } } return true; } } internal static class BridgeConstants { public const string BackendBaseUrl = "https://hyland-helper-backend.vercel.app"; public const string ModVersion = "0.1.3"; public const string TargetGameVersion = "0.4.5f2"; public const string TargetMelonLoaderVersion = "0.7.3"; public static readonly TimeSpan PairingPollInterval = TimeSpan.FromSeconds(5.0); public static readonly TimeSpan SnapshotInterval = TimeSpan.FromSeconds(30.0); public static readonly TimeSpan LatestPollInterval = TimeSpan.FromSeconds(30.0); public static readonly TimeSpan PhoneRecipePollInterval = TimeSpan.FromSeconds(30.0); public static readonly TimeSpan RetryBackoff = TimeSpan.FromSeconds(10.0); } internal enum BridgeDownlinkApplyOutcome { Applied, Cleared, Unchanged, NotPublished, TemporarilyUnavailable, Unsupported, NotConsented, EntitlementUnavailable, Malformed, PersistenceFailed } internal sealed class BridgeDownlinkCoordinator { private readonly PhoneRecipeCache _phoneRecipes; private readonly BridgeSettingsStore _settingsStore; private readonly BridgeLocalSettings _settings; private readonly Func _utcNow; private readonly AdvisorDispatchCache? _advisorDispatches; public bool PhoneRecipesRequireLegacyPoll { get; private set; } public bool AdvisorDispatchesSupported { get; private set; } = true; public BridgeDownlinkCoordinator(PhoneRecipeCache phoneRecipes, BridgeSettingsStore settingsStore, BridgeLocalSettings settings, Func utcNow, AdvisorDispatchCache? advisorDispatches = null) { _phoneRecipes = phoneRecipes; _settingsStore = settingsStore; _settings = settings; _utcNow = utcNow; _advisorDispatches = advisorDispatches; } public void PopulateKnownRevisions(BridgeExchangeRequestDownlink downlink) { downlink.PhoneRecipes = _settings.PhoneRecipesRevision; downlink.AdvisorDispatches = _settings.AdvisorDispatchesRevision; } public BridgeDownlinkApplyOutcome ApplyPhoneRecipes(BridgePhoneRecipeExchangeSlice? slice, string saveFingerprint) { if (!SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint) || slice == null || string.IsNullOrWhiteSpace(slice.State)) { return BridgeDownlinkApplyOutcome.Malformed; } switch (slice.State) { case "changed": if (!ValidRevision(slice.Revision) || slice.Payload == null) { return BridgeDownlinkApplyOutcome.Malformed; } PhoneRecipesRequireLegacyPoll = false; return ReplacePhoneRecipes(slice.Payload, slice.Revision, saveFingerprint); case "unchanged": if (!ValidRevision(slice.Revision) || !string.Equals(slice.Revision, _settings.PhoneRecipesRevision, StringComparison.Ordinal)) { return BridgeDownlinkApplyOutcome.Malformed; } PhoneRecipesRequireLegacyPoll = false; return BridgeDownlinkApplyOutcome.Unchanged; case "cleared": if (!ValidRevision(slice.Revision)) { return BridgeDownlinkApplyOutcome.Malformed; } PhoneRecipesRequireLegacyPoll = false; return ClearPhoneRecipes(slice.Revision, saveFingerprint); case "notPublished": if (slice.Revision != null || slice.Payload != null) { return BridgeDownlinkApplyOutcome.Malformed; } PhoneRecipesRequireLegacyPoll = false; return BridgeDownlinkApplyOutcome.NotPublished; case "temporarilyUnavailable": if (slice.Payload != null || (slice.Revision != null && !ValidRevision(slice.Revision)) || !ValidRetryAfter(slice.RetryAfterSeconds)) { return BridgeDownlinkApplyOutcome.Malformed; } PhoneRecipesRequireLegacyPoll = false; _phoneRecipes.MarkOffline(_utcNow(), saveFingerprint); return BridgeDownlinkApplyOutcome.TemporarilyUnavailable; case "unsupported": if (slice.Payload != null || (slice.Revision != null && !ValidRevision(slice.Revision)) || slice.RetryAfterSeconds.HasValue) { return BridgeDownlinkApplyOutcome.Malformed; } PhoneRecipesRequireLegacyPoll = true; return BridgeDownlinkApplyOutcome.Unsupported; default: return BridgeDownlinkApplyOutcome.Malformed; } } public BridgeDownlinkApplyOutcome ApplyAdvisorDispatches(BridgeAdvisorDispatchExchangeSlice? slice, string saveFingerprint) { if (!SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint) || _advisorDispatches == null || slice == null || string.IsNullOrWhiteSpace(slice.State)) { return BridgeDownlinkApplyOutcome.Malformed; } switch (slice.State) { case "changed": if (!ValidRevision(slice.Revision) || slice.Payload == null) { return BridgeDownlinkApplyOutcome.Malformed; } AdvisorDispatchesSupported = true; return ReplaceAdvisorDispatches(slice.Payload, slice.Revision, saveFingerprint); case "unchanged": if (!ValidRevision(slice.Revision) || !string.Equals(slice.Revision, _settings.AdvisorDispatchesRevision, StringComparison.Ordinal)) { return BridgeDownlinkApplyOutcome.Malformed; } AdvisorDispatchesSupported = true; return BridgeDownlinkApplyOutcome.Unchanged; case "cleared": if (!ValidRevision(slice.Revision)) { return BridgeDownlinkApplyOutcome.Malformed; } AdvisorDispatchesSupported = true; return ClearAdvisorDispatches(slice.Revision, saveFingerprint); case "notConsented": if (slice.Payload != null || (slice.Revision != null && !ValidRevision(slice.Revision))) { return BridgeDownlinkApplyOutcome.Malformed; } AdvisorDispatchesSupported = true; return BridgeDownlinkApplyOutcome.NotConsented; case "entitlementUnavailable": if (slice.Payload != null || (slice.Revision != null && !ValidRevision(slice.Revision))) { return BridgeDownlinkApplyOutcome.Malformed; } AdvisorDispatchesSupported = true; return BridgeDownlinkApplyOutcome.EntitlementUnavailable; case "temporarilyUnavailable": if (slice.Payload != null || (slice.Revision != null && !ValidRevision(slice.Revision)) || !ValidRetryAfter(slice.RetryAfterSeconds)) { return BridgeDownlinkApplyOutcome.Malformed; } AdvisorDispatchesSupported = true; return BridgeDownlinkApplyOutcome.TemporarilyUnavailable; case "unsupported": if (slice.Payload != null || (slice.Revision != null && !ValidRevision(slice.Revision)) || slice.RetryAfterSeconds.HasValue) { return BridgeDownlinkApplyOutcome.Malformed; } AdvisorDispatchesSupported = false; return BridgeDownlinkApplyOutcome.Unsupported; default: return BridgeDownlinkApplyOutcome.Malformed; } } public void ClearTokenScopedState() { _phoneRecipes.Clear(); _advisorDispatches?.Clear(); PhoneRecipesRequireLegacyPoll = false; AdvisorDispatchesSupported = true; } private BridgeDownlinkApplyOutcome ReplacePhoneRecipes(BridgePhoneRecipeSnapshot snapshot, string revision, string saveFingerprint) { if (!_phoneRecipes.TryCapture(out PhoneRecipeCacheCheckpoint checkpoint)) { return BridgeDownlinkApplyOutcome.PersistenceFailed; } if (!_phoneRecipes.Write(snapshot, _utcNow(), saveFingerprint)) { return BridgeDownlinkApplyOutcome.PersistenceFailed; } return PersistPhoneRevision(revision, checkpoint, BridgeDownlinkApplyOutcome.Applied); } private BridgeDownlinkApplyOutcome ClearPhoneRecipes(string revision, string saveFingerprint) { if (!_phoneRecipes.TryCapture(out PhoneRecipeCacheCheckpoint checkpoint)) { return BridgeDownlinkApplyOutcome.PersistenceFailed; } if (!_phoneRecipes.ClearForScope(saveFingerprint)) { return BridgeDownlinkApplyOutcome.PersistenceFailed; } return PersistPhoneRevision(revision, checkpoint, BridgeDownlinkApplyOutcome.Cleared); } private BridgeDownlinkApplyOutcome PersistPhoneRevision(string revision, PhoneRecipeCacheCheckpoint checkpoint, BridgeDownlinkApplyOutcome success) { string phoneRecipesRevision = _settings.PhoneRecipesRevision; _settings.PhoneRecipesRevision = revision; if (_settingsStore.Save(_settings)) { return success; } _settings.PhoneRecipesRevision = phoneRecipesRevision; _phoneRecipes.Restore(checkpoint); return BridgeDownlinkApplyOutcome.PersistenceFailed; } private BridgeDownlinkApplyOutcome ReplaceAdvisorDispatches(BridgeAdvisorDispatchPayload payload, string revision, string saveFingerprint) { if (!AdvisorDispatchCache.CanWrite(payload, revision, _utcNow())) { return BridgeDownlinkApplyOutcome.Malformed; } if (_advisorDispatches == null || !_advisorDispatches.TryCapture(out AdvisorDispatchCacheCheckpoint checkpoint)) { return BridgeDownlinkApplyOutcome.PersistenceFailed; } if (!_advisorDispatches.Write(payload, revision, _utcNow(), saveFingerprint)) { return BridgeDownlinkApplyOutcome.PersistenceFailed; } return PersistAdvisorRevision(revision, checkpoint, BridgeDownlinkApplyOutcome.Applied); } private BridgeDownlinkApplyOutcome ClearAdvisorDispatches(string revision, string saveFingerprint) { if (_advisorDispatches == null || !_advisorDispatches.TryCapture(out AdvisorDispatchCacheCheckpoint checkpoint)) { return BridgeDownlinkApplyOutcome.PersistenceFailed; } if (!_advisorDispatches.ClearForScope(saveFingerprint)) { return BridgeDownlinkApplyOutcome.PersistenceFailed; } return PersistAdvisorRevision(revision, checkpoint, BridgeDownlinkApplyOutcome.Cleared); } private BridgeDownlinkApplyOutcome PersistAdvisorRevision(string revision, AdvisorDispatchCacheCheckpoint checkpoint, BridgeDownlinkApplyOutcome success) { string advisorDispatchesRevision = _settings.AdvisorDispatchesRevision; _settings.AdvisorDispatchesRevision = revision; if (_settingsStore.Save(_settings)) { return success; } _settings.AdvisorDispatchesRevision = advisorDispatchesRevision; _advisorDispatches.Restore(checkpoint); return BridgeDownlinkApplyOutcome.PersistenceFailed; } private static bool ValidRevision(string? revision) { return PairingScope.IsBounded(revision, 128); } private static bool ValidRetryAfter(int? retryAfterSeconds) { if (retryAfterSeconds.HasValue) { if (retryAfterSeconds.HasValue) { int valueOrDefault = retryAfterSeconds.GetValueOrDefault(); if (valueOrDefault >= 0) { return valueOrDefault <= 86400; } } return false; } return true; } } internal enum BridgeExchangeMode { Negotiating, Exchange, Legacy, Repair } internal sealed record BridgeAcknowledgementResult(long SequenceFloor, bool RequiresFullResynchronization); internal sealed class BridgeExchangeState { private DateTimeOffset _retryAt = DateTimeOffset.MinValue; private int _attempt; public BridgeExchangeMode Mode { get; private set; } public bool CanSendHeartbeat => Mode == BridgeExchangeMode.Exchange; public bool CanAttemptExchange(DateTimeOffset now) { BridgeExchangeMode mode = Mode; if ((uint)mode <= 1u) { return now >= _retryAt; } return false; } public BridgeAcknowledgementResult ApplySuccess(BridgeIngestAcknowledgement acknowledgement, long localSequence, DateTimeOffset now) { Mode = BridgeExchangeMode.Exchange; _attempt = 0; _retryAt = DateTimeOffset.MinValue; return new BridgeAcknowledgementResult(Math.Max(localSequence, Math.Max(acknowledgement.Sequence, acknowledgement.CurrentSequence.GetValueOrDefault())), acknowledgement.Stale || !acknowledgement.Stored); } public BridgeRetryDecision ApplyFailure(Exception exception, BridgeRetryPolicy policy, DateTimeOffset now) { BridgeRetryDecision bridgeRetryDecision = policy.Decide(exception, _attempt, now); switch (bridgeRetryDecision.Action) { case BridgeRetryAction.StickyLegacy: Mode = BridgeExchangeMode.Legacy; _retryAt = DateTimeOffset.MaxValue; break; case BridgeRetryAction.Repair: Mode = BridgeExchangeMode.Repair; _retryAt = DateTimeOffset.MaxValue; break; case BridgeRetryAction.Retry: case BridgeRetryAction.Resynchronize: _retryAt = now + (bridgeRetryDecision.Delay ?? BridgeRetryPolicy.MinDelay); _attempt++; break; } return bridgeRetryDecision; } public void EnterRepair() { Mode = BridgeExchangeMode.Repair; _retryAt = DateTimeOffset.MaxValue; } public void Reset() { Mode = BridgeExchangeMode.Negotiating; _retryAt = DateTimeOffset.MinValue; _attempt = 0; } } public static class BridgeFeatureAdvertisement { public const string PhoneRecipeAvailability = "phoneRecipeAvailability"; public const int PhoneRecipeProtocolVersion = 1; public const string BridgeExchange = "bridgeExchange"; public const string AdvisorDispatches = "advisorDispatches"; public const int ExchangeProtocolVersion = 1; public const int AdvisorDispatchProtocolVersion = 1; public static Dictionary Current(bool exchangeOperational = false, bool advisorOperational = false) { Dictionary dictionary = new Dictionary { ["phoneRecipeAvailability"] = new BridgeFeatureCapability { Supported = true, ProtocolVersion = 1 } }; if (exchangeOperational) { dictionary["bridgeExchange"] = new BridgeFeatureCapability { Supported = true, ProtocolVersion = 1 }; } if (exchangeOperational && advisorOperational) { dictionary["advisorDispatches"] = new BridgeFeatureCapability { Supported = true, ProtocolVersion = 1 }; } return dictionary; } } internal sealed class BridgeOperationalCapabilityState { private bool _exchangeOperational; private bool _advisorOperational; public bool Update(BridgeExchangeMode mode, bool tileReady, bool advisorAvailable = true) { bool flag = mode == BridgeExchangeMode.Exchange; bool flag2 = flag && tileReady && advisorAvailable; if (flag == _exchangeOperational && flag2 == _advisorOperational) { return false; } _exchangeOperational = flag; _advisorOperational = flag2; return true; } public Dictionary Current() { return BridgeFeatureAdvertisement.Current(_exchangeOperational, _advisorOperational); } } public sealed class HylandHelperBridgeMod : MelonMod { private readonly OverlayState _overlay = new OverlayState(); private CancellationTokenSource? _cts; private BridgeRunner? _runner; public override void OnInitializeMelon() { try { _cts = new CancellationTokenSource(); MelonBridgeLogger logger = new MelonBridgeLogger(((MelonBase)this).LoggerInstance); BridgeOperationalCapabilityState bridgeOperationalCapabilityState = new BridgeOperationalCapabilityState(); GameAdapter gameAdapter = new GameAdapter(logger, bridgeOperationalCapabilityState.Current); BridgeSettingsStore settingsStore = BridgeSettingsStore.Create(logger); BridgeClient bridgeClient = new BridgeClient("https://hyland-helper-backend.vercel.app", logger); _runner = new BridgeRunner(gameAdapter, bridgeClient, settingsStore, _overlay, logger, bridgeOperationalCapabilityState); _runner.RunAsync(_cts.Token); _overlay.SetStatus("Starting bridge..."); } catch (Exception ex) { ((MelonBase)this).LoggerInstance.Warning("Hyland Helper Bridge disabled during init: " + ex.Message); _overlay.SetStatus("Bridge disabled during init."); } } public override void OnDeinitializeMelon() { try { _cts?.Cancel(); _runner?.Dispose(); _cts?.Dispose(); } catch { } } public override void OnGUI() { try { OverlaySnapshot overlaySnapshot = _overlay.Snapshot(); if (overlaySnapshot.Status == null || (!overlaySnapshot.Status.Contains("synced") && !overlaySnapshot.Status.Contains("Paired"))) { OverlayRenderer.Draw(_overlay); } } catch { } } } internal sealed class BridgePreferencesStore { public const int SchemaVersionCurrent = 1; public const int MaxPreferencesBytes = 4096; private readonly string _path; internal static string DefaultPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "TVGS", "Schedule I", "HylandHelper", "bridge-preferences.json"); internal BridgePreferencesStore(string? path = null) { _path = path ?? DefaultPath; } internal bool ReadLiveOverlayEnabled() { return ReadEnabledFlag("liveOverlayEnabled"); } internal bool ReadAdaptiveCadenceEnabled() { return ReadEnabledFlag("adaptiveCadenceEnabled"); } private bool ReadEnabledFlag(string property) { try { FileInfo fileInfo = new FileInfo(_path); if (!fileInfo.Exists || fileInfo.Length <= 0 || fileInfo.Length > 4096) { return true; } byte[] array = File.ReadAllBytes(_path); if (array.Length == 0 || array.Length > 4096) { return true; } using JsonDocument jsonDocument = JsonDocument.Parse(array); JsonElement rootElement = jsonDocument.RootElement; if (rootElement.ValueKind != JsonValueKind.Object) { return true; } if (!rootElement.TryGetProperty(property, out var value)) { return true; } return value.ValueKind != JsonValueKind.False; } catch { return true; } } internal bool Save(bool liveOverlayEnabled) { try { string directoryName = Path.GetDirectoryName(_path); if (!string.IsNullOrEmpty(directoryName)) { Directory.CreateDirectory(directoryName); } string s = "{\"schemaVersion\":" + 1 + ",\"liveOverlayEnabled\":" + (liveOverlayEnabled ? "true" : "false") + "}"; string text = $"{_path}.{Guid.NewGuid():N}.tmp"; try { File.WriteAllBytes(text, Encoding.UTF8.GetBytes(s)); File.Move(text, _path, overwrite: true); } finally { try { if (File.Exists(text)) { File.Delete(text); } } catch { } } return true; } catch { return false; } } } internal enum BridgeRetryAction { Continue, Retry, Repair, StickyLegacy, Resynchronize, Stop } internal sealed record BridgeRetryDecision(BridgeRetryAction Action, TimeSpan? Delay = null); internal sealed class BridgeRetryPolicy { public static readonly TimeSpan MinDelay = TimeSpan.FromSeconds(2.0); public static readonly TimeSpan MaxDelay = TimeSpan.FromMinutes(2.0); private readonly Func _nextJitter; public BridgeRetryPolicy(Func? nextJitter = null) { _nextJitter = nextJitter ?? new Func(Random.Shared.NextDouble); } public BridgeRetryDecision Decide(Exception exception, int attempt, DateTimeOffset now) { if ((exception is HttpRequestException || exception is TaskCanceledException) ? true : false) { return Retry(attempt, null); } if (exception is BridgePayloadException) { return Retry(attempt, null); } if (!(exception is BridgeClientException { StatusCode: var statusCode } ex)) { return new BridgeRetryDecision(BridgeRetryAction.Stop); } switch (statusCode) { case HttpStatusCode.Unauthorized: return new BridgeRetryDecision(BridgeRetryAction.Repair); case HttpStatusCode.Forbidden: { string code = ex.Code; if ((code == "SAVE_SCOPE_MISMATCH" || code == "PAIRING_SCOPE_MISMATCH") ? true : false) { return new BridgeRetryDecision(BridgeRetryAction.Repair); } return new BridgeRetryDecision(BridgeRetryAction.Continue); } case HttpStatusCode.NotFound: return new BridgeRetryDecision(BridgeRetryAction.StickyLegacy); case HttpStatusCode.Conflict: return Retry(attempt, ex.RetryAfter, BridgeRetryAction.Resynchronize); case HttpStatusCode.TooManyRequests: return Retry(attempt, ex.RetryAfter); default: if (ex.IsTransient) { return Retry(attempt, ex.RetryAfter); } return new BridgeRetryDecision(BridgeRetryAction.Stop); } } private BridgeRetryDecision Retry(int attempt, TimeSpan? serverDelay, BridgeRetryAction action = BridgeRetryAction.Retry) { if (serverDelay.HasValue) { return new BridgeRetryDecision(action, Clamp(serverDelay.Value)); } int num = Math.Clamp(attempt, 0, 16); double num2 = MinDelay.TotalSeconds * Math.Pow(2.0, num); double num3 = 0.5 + Math.Clamp(_nextJitter(), 0.0, 1.0) / 2.0; return new BridgeRetryDecision(action, Clamp(TimeSpan.FromSeconds(num2 * num3))); } private static TimeSpan Clamp(TimeSpan delay) { if (!(delay < MinDelay)) { if (!(delay > MaxDelay)) { return delay; } return MaxDelay; } return MinDelay; } } internal sealed class BridgeRunner : IDisposable { private readonly IBridgeGameAdapter _gameAdapter; private readonly BridgeClient _bridgeClient; private readonly BridgeSettingsStore _settingsStore; private readonly OverlayState _overlay; private readonly MelonBridgeLogger _logger; private readonly BridgeStatusWriter _statusWriter; private readonly BridgeCommandReader _commandReader; private readonly BridgeCommandResultWriter _commandResultWriter; private readonly MyMixesWriter _myMixesWriter; private readonly PhoneRecipePoller _phoneRecipePoller; private readonly TileChallengeState _tileChallenge; private readonly BridgeOperationalCapabilityState _capabilities; private readonly BridgeDownlinkCoordinator _downlinks; private readonly LocalRefreshInvalidationHub _refreshInvalidations = new LocalRefreshInvalidationHub(); private readonly BridgeRetryPolicy _retryPolicy = new BridgeRetryPolicy(); private readonly Func _utcNow; private readonly TimeSpan _exchangeCadence; private readonly TimeSpan _safetyCadence; private BridgeExchangeState _exchangeState = new BridgeExchangeState(); private BridgeSnapshotCoordinator _snapshotCoordinator; private DateTimeOffset _nextMyMixes = DateTimeOffset.MinValue; private BridgeLocalSettings _settings; private PairingSession? _pairing; private PairingScope? _tokenScope; private DateTimeOffset _nextPairingPoll = DateTimeOffset.MinValue; private DateTimeOffset _nextLatestPoll = DateTimeOffset.MinValue; private string? _lastImmediateRequestId; private bool _disposed; private readonly LiveOverlayReader? _liveOverlayReader; private readonly LiveFactBaseline? _liveFactBaseline; private readonly LiveFactMerger? _liveFactMerger; private readonly ScreenActivityReader? _screenActivityReader; private readonly bool _adaptiveCadenceEnabled; private BridgeCadenceController? _cadence; private string? _activeGenerationV2; private string? _lastLiveOverlayGeneration; internal ILocalRefreshInvalidationSource RefreshInvalidations => _refreshInvalidations; internal BridgeExchangeMode CurrentMode => _exchangeState.Mode; internal BridgeRunner(IBridgeGameAdapter gameAdapter, BridgeClient bridgeClient, BridgeSettingsStore settingsStore, OverlayState overlay, MelonBridgeLogger logger, BridgeOperationalCapabilityState? capabilities = null) : this(gameAdapter, bridgeClient, settingsStore, overlay, logger, capabilities ?? new BridgeOperationalCapabilityState(), () => DateTimeOffset.UtcNow, new PhoneRecipeCache(PhoneRecipeCache.DefaultPath), new AdvisorDispatchCache(AdvisorDispatchCache.DefaultPath), new TileChallengeState(TileChallengeState.DefaultPath), new BridgeStatusWriter(logger), null, null, null, new BridgePreferencesStore().ReadLiveOverlayEnabled(), null, new BridgePreferencesStore().ReadAdaptiveCadenceEnabled()) { } internal BridgeRunner(IBridgeGameAdapter gameAdapter, BridgeClient bridgeClient, BridgeSettingsStore settingsStore, OverlayState overlay, MelonBridgeLogger logger, BridgeOperationalCapabilityState capabilities, Func utcNow, PhoneRecipeCache phoneRecipeCache, AdvisorDispatchCache advisorDispatchCache, TileChallengeState tileChallenge, BridgeStatusWriter statusWriter, TimeSpan? exchangeCadence = null, TimeSpan? safetyCadence = null, MyMixesWriter? myMixesWriter = null, bool liveOverlayEnabled = true, string? liveOverlayPath = null, bool adaptiveCadenceEnabled = false, string? screenActivityPath = null) { _gameAdapter = gameAdapter; _bridgeClient = bridgeClient; _settingsStore = settingsStore; _overlay = overlay; _logger = logger; _capabilities = capabilities; _utcNow = utcNow; _exchangeCadence = exchangeCadence ?? BridgeConstants.SnapshotInterval; _safetyCadence = safetyCadence ?? TimeSpan.FromMinutes(5.0); _adaptiveCadenceEnabled = adaptiveCadenceEnabled; _statusWriter = statusWriter; _commandReader = new BridgeCommandReader(logger); _commandResultWriter = new BridgeCommandResultWriter(logger); _myMixesWriter = myMixesWriter ?? new MyMixesWriter(logger); _tileChallenge = tileChallenge; _snapshotCoordinator = NewSnapshotCoordinator(); if (adaptiveCadenceEnabled) { _screenActivityReader = new ScreenActivityReader(() => _activeGenerationV2, () => _statusWriter.RuntimeSession, screenActivityPath); } _settings = _settingsStore.Load(); _tokenScope = _settings.Scope; UpdateUnlinkAvailability(); _downlinks = new BridgeDownlinkCoordinator(phoneRecipeCache, _settingsStore, _settings, _utcNow, advisorDispatchCache); _phoneRecipePoller = new PhoneRecipePoller(_bridgeClient, phoneRecipeCache, _utcNow, BridgeConstants.PhoneRecipePollInterval); _overlay.TileChallenge = _tileChallenge.Status; if (!liveOverlayEnabled) { return; } _liveOverlayReader = new LiveOverlayReader(() => _activeGenerationV2, () => _statusWriter.RuntimeSession, liveOverlayPath); _liveFactBaseline = new LiveFactBaseline(); _liveFactMerger = new LiveFactMerger(_liveFactBaseline); if (gameAdapter is GameAdapter gameAdapter2) { gameAdapter2.SnapshotPostProcessor = (ParsedSnapshot parsed) => _liveFactMerger.Merge(parsed, _liveOverlayReader.TryGetCurrent(_utcNow(), out LiveOverlayDocument document) ? document : null); } } public async Task RunAsync(CancellationToken cancellationToken) { _logger.Info("Hyland Helper Bridge started."); while (!cancellationToken.IsCancellationRequested) { try { DetectedSave detectedSave = _gameAdapter.DetectCurrentSave(); _overlay.SaveLine = detectedSave.StatusLine; if (_utcNow() >= _nextMyMixes && CanWriteMyMixes(detectedSave)) { _nextMyMixes = _utcNow() + TimeSpan.FromSeconds(10.0); _myMixesWriter.Write(detectedSave); } if (string.IsNullOrWhiteSpace(_settings.BridgeToken)) { if (!_commandReader.TakeUnlink()) { await EnsurePairingAsync(detectedSave, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } else { ResetStoredToken("Unlinked from in-game menu; restarting pairing."); } } else { BridgeCommand bridgeCommand = _commandReader.TakeCommand(); bool unlink = bridgeCommand != null && string.Equals(bridgeCommand.Command, "unlink", StringComparison.OrdinalIgnoreCase); await RunCycleAsync(detectedSave, bridgeCommand, unlink, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } catch (BridgeClientException ex2) when (ex2.IsUnauthorized) { ResetStoredToken("Stored bridge token was rejected; restarting pairing."); _logger.WarningThrottled("Stored bridge token was rejected; restarting pairing.", ex2); } catch (Exception exception) { _overlay.SetStatus("Bridge quiet-failed; retrying."); _logger.WarningThrottled("Bridge loop quiet-failed; retrying.", exception); await DelayQuietlyAsync(BridgeConstants.RetryBackoff, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } _statusWriter.Write(_overlay.Snapshot()); await DelayQuietlyAsync(TimeSpan.FromSeconds(1.0), cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } } internal async Task RunCycleAsync(DetectedSave save, BridgeCommand? command, bool unlink, CancellationToken cancellationToken) { if (unlink) { ResetStoredToken("Unlinked from in-game menu; restarting pairing."); return; } ObserveLiveOverlayGeneration(save); if (string.IsNullOrWhiteSpace(_settings.BridgeToken)) { return; } if (_settings.RequiresGenerationRePair || _tokenScope == null) { _overlay.SetStatus("This install needs one re-pair to protect replacement save slots."); return; } if (!save.IsPairingEligible) { _overlay.SetStatus("Save generation details are not ready. Finish loading, then retry pairing."); return; } if (!_tokenScope.Matches(save.Fingerprint)) { _exchangeState.EnterRepair(); _overlay.SetStatus("Linked save changed; unlink and pair this playthrough again."); return; } if (command != null && !string.Equals(command.Command, "unlink", StringComparison.OrdinalIgnoreCase) && (!SaveGenerationIdentity.IsV2Fingerprint(command.SaveFingerprint) || !string.Equals(command.SaveFingerprint, save.Fingerprint.Fingerprint, StringComparison.Ordinal))) { _overlay.SetStatus("Ignored a command queued for a different save playthrough."); return; } try { PumpScreenActivity(); PumpLiveOverlay(); if (command != null && string.Equals(command.Command, "syncNow", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(command.RequestId) && !string.Equals(command.RequestId, _lastImmediateRequestId, StringComparison.Ordinal)) { _lastImmediateRequestId = command.RequestId; _snapshotCoordinator.RequestFull(BridgeFullSnapshotReason.SyncNow); _myMixesWriter.Invalidate(save.Fingerprint.Fingerprint); _refreshInvalidations.Invalidate(LocalRefreshSurface.ActiveSave | LocalRefreshSurface.MyMixes); _overlay.SetStatus("Save-complete sync requested."); _logger.Info("Save-complete sync requested."); } if (command != null && string.Equals(command.Command, "saveMix", StringComparison.OrdinalIgnoreCase) && command.Mix != null && !string.IsNullOrWhiteSpace(command.RequestId)) { await SendPendingMixAsync(command, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } await RunTransportCycleAsync(save, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } catch (BridgeClientException ex) when (ex.IsUnauthorized) { ResetStoredToken("Stored bridge token was rejected; restarting pairing."); _logger.WarningThrottled("Stored bridge token was rejected; restarting pairing.", ex); } finally { _statusWriter.Write(_overlay.Snapshot()); } } private async Task RunTransportCycleAsync(DetectedSave save, CancellationToken cancellationToken) { _tileChallenge.Refresh(); if (save.IsFallback) { if (UpdateOperationalCapabilities(BridgeExchangeMode.Negotiating)) { _snapshotCoordinator.RequestFull(BridgeFullSnapshotReason.CapabilityChanged); } _overlay.SetStatus("Waiting for a validated save."); return; } _snapshotCoordinator.ObserveSave(save, _tokenScope.SaveFingerprint); if (UpdateOperationalCapabilities(_exchangeState.Mode)) { _snapshotCoordinator.RequestFull(BridgeFullSnapshotReason.CapabilityChanged); } if (_exchangeState.Mode == BridgeExchangeMode.Repair || _snapshotCoordinator.RepairRequired) { _exchangeState.EnterRepair(); _overlay.SetStatus("Linked save changed; unlink and pair this playthrough again."); return; } if (_exchangeState.Mode == BridgeExchangeMode.Legacy) { await RunLegacyCycleAsync(save, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); return; } if (!_exchangeState.CanAttemptExchange(_utcNow())) { if (_downlinks.PhoneRecipesRequireLegacyPoll) { await _phoneRecipePoller.PollIfDueAsync(_settings.BridgeToken, save.Fingerprint.Fingerprint, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } return; } BridgeEnvelopePlan plan = _snapshotCoordinator.TakeDue(_utcNow(), _exchangeState.Mode); if (plan.Kind == BridgeEnvelopeKind.None) { if (_downlinks.PhoneRecipesRequireLegacyPoll) { await _phoneRecipePoller.PollIfDueAsync(_settings.BridgeToken, save.Fingerprint.Fingerprint, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } return; } long sequence = _settingsStore.NextSequence(_settings, _utcNow()); BridgeEnvelope envelope = ((plan.Kind == BridgeEnvelopeKind.Heartbeat) ? _gameAdapter.BuildHeartbeat(save, sequence) : _gameAdapter.BuildSnapshot(save, sequence)); BridgeExchangeRequest request = new BridgeExchangeRequest { Envelope = envelope }; _downlinks.PopulateKnownRevisions(request.Downlink); BridgeExchangeResponse bridgeExchangeResponse; try { bridgeExchangeResponse = await _bridgeClient.ExchangeAsync(_settings.BridgeToken, request, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } catch (BridgeClientException ex) when (ex.IsUnauthorized) { throw; } catch (Exception ex2) when (!(ex2 is OperationCanceledException) || !cancellationToken.IsCancellationRequested) { BridgeRetryDecision bridgeRetryDecision = _exchangeState.ApplyFailure(ex2, _retryPolicy, _utcNow()); if (bridgeRetryDecision.Action == BridgeRetryAction.Resynchronize) { _snapshotCoordinator.RequestFull(BridgeFullSnapshotReason.StaleAcknowledgement); } if (UpdateOperationalCapabilities(_exchangeState.Mode)) { _snapshotCoordinator.RequestFull(BridgeFullSnapshotReason.CapabilityChanged); } OverlayState overlay = _overlay; overlay.SetStatus(bridgeRetryDecision.Action switch { BridgeRetryAction.StickyLegacy => "Exchange unavailable; using legacy bridge mode.", BridgeRetryAction.Repair => "Linked save changed; re-pair in the phone app to continue.", _ => "Exchange temporarily unavailable; retrying quietly.", }); return; } DetectedSave detectedSave = _gameAdapter.DetectCurrentSave(); if (!detectedSave.IsPairingEligible) { _overlay.SetStatus("Save generation details are not ready. Retrying before applying synced data."); _snapshotCoordinator.RequestFull(BridgeFullSnapshotReason.StaleAcknowledgement); return; } if (_tokenScope == null || !_tokenScope.Matches(detectedSave.Fingerprint)) { _exchangeState.EnterRepair(); _overlay.SetStatus("Linked save changed; unlink and pair this playthrough again."); return; } BridgeAcknowledgementResult bridgeAcknowledgementResult = _exchangeState.ApplySuccess(bridgeExchangeResponse.Ingest, _settings.LastSequence, _utcNow()); if (bridgeAcknowledgementResult.SequenceFloor > _settings.LastSequence) { long lastSequence = _settings.LastSequence; _settings.LastSequence = bridgeAcknowledgementResult.SequenceFloor; if (!_settingsStore.Save(_settings)) { _settings.LastSequence = lastSequence; _snapshotCoordinator.RequestFull(BridgeFullSnapshotReason.StaleAcknowledgement); _overlay.SetStatus("Sequence floor persistence failed; retrying full sync."); return; } } BridgeDownlinkApplyOutcome outcome = _downlinks.ApplyPhoneRecipes(bridgeExchangeResponse.Downlink.PhoneRecipes, save.Fingerprint.Fingerprint); BridgeDownlinkApplyOutcome outcome2 = _downlinks.ApplyAdvisorDispatches(bridgeExchangeResponse.Downlink.AdvisorDispatches, save.Fingerprint.Fingerprint); _cadence?.NotifyHint(bridgeExchangeResponse.PollHintSeconds, AcceptedFreshDelivery.Evaluate(bridgeExchangeResponse.Ingest, outcome, request.Downlink.PhoneRecipes, bridgeExchangeResponse.Downlink.PhoneRecipes?.Revision) || AcceptedFreshDelivery.Evaluate(bridgeExchangeResponse.Ingest, outcome2, request.Downlink.AdvisorDispatches, bridgeExchangeResponse.Downlink.AdvisorDispatches?.Revision), _utcNow()); _snapshotCoordinator.Complete(plan, _utcNow()); if (plan.Kind == BridgeEnvelopeKind.FullSnapshot && bridgeExchangeResponse.Ingest.Stored && !bridgeExchangeResponse.Ingest.Stale) { CommitDeliveredRuntimeBaselines(); } if (plan.EnterRepairAfterSend || _snapshotCoordinator.RepairRequired) { _exchangeState.EnterRepair(); } if (bridgeAcknowledgementResult.RequiresFullResynchronization) { _snapshotCoordinator.RequestFull(BridgeFullSnapshotReason.StaleAcknowledgement); } if (UpdateOperationalCapabilities(_exchangeState.Mode)) { _snapshotCoordinator.RequestFull(BridgeFullSnapshotReason.CapabilityChanged); } if (_downlinks.PhoneRecipesRequireLegacyPoll) { await _phoneRecipePoller.PollIfDueAsync(_settings.BridgeToken, save.Fingerprint.Fingerprint, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } _overlay.Sequence = sequence; _overlay.SetStatus((plan.Kind == BridgeEnvelopeKind.Heartbeat) ? "Bridge live; synced." : "Snapshot synced."); } private async Task RunLegacyCycleAsync(DetectedSave save, CancellationToken cancellationToken) { BridgeEnvelopePlan plan = _snapshotCoordinator.TakeDue(_utcNow(), BridgeExchangeMode.Legacy); if (plan.Kind == BridgeEnvelopeKind.FullSnapshot) { long sequence = _settingsStore.NextSequence(_settings, _utcNow()); BridgeEnvelope heartbeat = _gameAdapter.BuildSnapshot(save, sequence); try { await _bridgeClient.SendHeartbeatAsync(_settings.BridgeToken, heartbeat, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } catch (BridgeClientException ex) when (ex.StatusCode == HttpStatusCode.Forbidden) { _exchangeState.EnterRepair(); _overlay.SetStatus("Linked save changed; re-pair in the phone app to continue."); return; } _snapshotCoordinator.Complete(plan, _utcNow()); CommitDeliveredRuntimeBaselines(); _overlay.Sequence = sequence; } await PollLatestIfDueAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); await _phoneRecipePoller.PollIfDueAsync(_settings.BridgeToken, save.Fingerprint.Fingerprint, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); _overlay.SetStatus(_settings.RequiresScopeUpgrade ? "Paired token needs a one-time re-pair to restore linked-save metadata." : "Legacy bridge snapshot synced."); } private bool UpdateOperationalCapabilities(BridgeExchangeMode mode) { return _capabilities.Update(mode, _tileChallenge.IsReady, _downlinks.AdvisorDispatchesSupported); } private bool CanWriteMyMixes(DetectedSave save) { if (!save.IsPairingEligible) { return false; } if (string.IsNullOrWhiteSpace(_settings.BridgeToken)) { return true; } if (!_settings.RequiresGenerationRePair && _tokenScope != null) { return _tokenScope.Matches(save.Fingerprint); } return false; } private void ObserveLiveOverlayGeneration(DetectedSave save) { string text = (_activeGenerationV2 = (SaveGenerationIdentity.IsV2Fingerprint(save.Fingerprint.Fingerprint) ? save.Fingerprint.Fingerprint : null)); if (_liveOverlayReader != null && text != null) { if (_lastLiveOverlayGeneration != null && !string.Equals(_lastLiveOverlayGeneration, text, StringComparison.Ordinal)) { _liveOverlayReader.Invalidate(); _liveFactBaseline.NotifyRotation(); } _lastLiveOverlayGeneration = text; } } private void CommitDeliveredRuntimeBaselines() { if (_liveFactMerger == null || _liveFactBaseline == null) { return; } foreach (LiveFactDomain item in _liveFactMerger.TakeRuntimeBaselineDomains()) { _liveFactBaseline.CommitRuntimeBaseline(item); } } private void PumpLiveOverlay() { if (_liveOverlayReader == null) { return; } DateTimeOffset now = _utcNow(); LiveOverlayReadResult liveOverlayReadResult = _liveOverlayReader.ReadIfDue(now, dirty: false); if (liveOverlayReadResult.Status == LiveOverlayReadStatus.Accepted) { if (liveOverlayReadResult.Rotation) { _liveFactBaseline.NotifyRotation(); } _cadence?.OnOverlayAccepted(liveOverlayReadResult, now); } BridgeCadenceController? cadence = _cadence; if (cadence != null && cadence.ShouldRequestRuntimeObservationFull(now)) { _snapshotCoordinator.RequestFull(BridgeFullSnapshotReason.RuntimeObservation); } } private void ResetStoredToken(string status) { _downlinks.ClearTokenScopedState(); _settings.ClearTokenScopedState(); _settingsStore.Save(_settings); UpdateUnlinkAvailability(); _tokenScope = null; _pairing = null; _overlay.PairingCode = null; _overlay.LatestLine = "Latest: pending"; _overlay.SetStatus(status); _nextPairingPoll = DateTimeOffset.MinValue; _nextLatestPoll = DateTimeOffset.MinValue; _lastImmediateRequestId = null; _exchangeState.Reset(); _snapshotCoordinator = NewSnapshotCoordinator(); _capabilities.Update(BridgeExchangeMode.Negotiating, tileReady: false); _phoneRecipePoller.Reset(); _liveOverlayReader?.Invalidate(); _screenActivityReader?.Invalidate(); _liveFactBaseline?.NotifyRotation(); } private void UpdateUnlinkAvailability() { _overlay.CanUnlink = !string.IsNullOrWhiteSpace(_settings.BridgeToken); } public void Dispose() { if (!_disposed) { _disposed = true; _bridgeClient.Dispose(); } } private async Task EnsurePairingAsync(DetectedSave save, CancellationToken cancellationToken) { if (!CanStartPairing(save)) { _overlay.SetStatus("Waiting for a validated save before pairing."); } else if (_pairing == null) { _overlay.SetStatus("Requesting pairing code..."); _pairing = await _bridgeClient.StartPairingAsync(save.Fingerprint, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); _overlay.PairingCode = _pairing.Code; _overlay.SetStatus("Pairing pending."); _logger.Info("Pairing code: " + _pairing.Code); _nextPairingPoll = _utcNow() + BridgeConstants.PairingPollInterval; } else { if (_utcNow() < _nextPairingPoll) { return; } _nextPairingPoll = _utcNow() + BridgeConstants.PairingPollInterval; PairingStatusResponse pairingStatusResponse; try { pairingStatusResponse = await _bridgeClient.PollPairingStatusAsync(_pairing, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } catch (BridgeClientException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { _pairing = null; _overlay.PairingCode = null; _overlay.SetStatus("Pairing code expired; requesting a new one."); _logger.WarningThrottled("Pairing session expired; requesting a new code.", ex); return; } if (string.Equals(pairingStatusResponse.Status, "pending", StringComparison.OrdinalIgnoreCase)) { _overlay.SetStatus("Pairing pending."); } else if (string.Equals(pairingStatusResponse.Status, "revoked", StringComparison.OrdinalIgnoreCase)) { _overlay.SetStatus("Pairing revoked."); _pairing = null; _overlay.PairingCode = null; } else { if (string.IsNullOrWhiteSpace(pairingStatusResponse.BridgeToken)) { return; } PairingScope pairingScope = pairingStatusResponse.VerifiedScope ?? throw new InvalidOperationException("Claimed pairing response omitted its verified scope."); if (!pairingScope.Matches(save.Fingerprint)) { _pairing = null; _overlay.PairingCode = null; _overlay.SetStatus("Pairing save scope did not match; request a new pairing code."); return; } string bridgeToken = _settings.BridgeToken; PairingScope scope = _settings.Scope; string phoneRecipesRevision = _settings.PhoneRecipesRevision; string advisorDispatchesRevision = _settings.AdvisorDispatchesRevision; if (!string.Equals(_settings.BridgeToken, pairingStatusResponse.BridgeToken, StringComparison.Ordinal)) { _downlinks.ClearTokenScopedState(); _settings.ClearTokenScopedState(); } _settings.BridgeToken = pairingStatusResponse.BridgeToken; _settings.Scope = pairingScope; if (!_settingsStore.Save(_settings)) { _settings.BridgeToken = bridgeToken; _settings.Scope = scope; _settings.PhoneRecipesRevision = phoneRecipesRevision; _settings.AdvisorDispatchesRevision = advisorDispatchesRevision; UpdateUnlinkAvailability(); _overlay.SetStatus("Pairing claim could not be persisted; retrying."); } else { UpdateUnlinkAvailability(); _tokenScope = pairingScope; _pairing = null; _overlay.PairingCode = null; _overlay.SetStatus("Paired. Sending snapshot..."); _logger.Info("Bridge token received; snapshot sync enabled."); _exchangeState.Reset(); _snapshotCoordinator = NewSnapshotCoordinator(); _snapshotCoordinator.RequestFull(BridgeFullSnapshotReason.Pairing); _nextLatestPoll = DateTimeOffset.MinValue; _phoneRecipePoller.Reset(); } } } } internal static bool CanStartPairing(DetectedSave save) { if (save != null) { return save.IsPairingEligible; } return false; } private async Task PollLatestIfDueAsync(CancellationToken cancellationToken) { if (_tokenScope != null && !(_utcNow() < _nextLatestPoll)) { _nextLatestPoll = _utcNow() + BridgeConstants.LatestPollInterval; LatestStateResponse latestStateResponse = await _bridgeClient.GetLatestAsync(_settings.BridgeToken, _tokenScope, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (latestStateResponse != null && latestStateResponse.Sequence.HasValue) { _overlay.LatestLine = $"Latest sequence: {latestStateResponse.Sequence}"; } } } private async Task SendPendingMixAsync(BridgeCommand command, CancellationToken cancellationToken) { try { PendingMixPostResponse pendingMixPostResponse = await _bridgeClient.SendPendingMixAsync(_settings.BridgeToken, command.Mix, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); _commandResultWriter.Write(command.RequestId, (pendingMixPostResponse == null) ? "failed" : (pendingMixPostResponse.Deduped ? "alreadySent" : "sent"), command.SaveFingerprint); } catch (BridgeClientException ex) when (ex.IsUnauthorized) { _commandResultWriter.Write(command.RequestId, "unauthorized", command.SaveFingerprint); throw; } } private BridgeSnapshotCoordinator NewSnapshotCoordinator() { _cadence = new BridgeCadenceController(_exchangeCadence, _adaptiveCadenceEnabled); return new BridgeSnapshotCoordinator(_exchangeCadence, _safetyCadence, _cadence); } private void PumpScreenActivity() { if (_screenActivityReader != null && _cadence != null) { DateTimeOffset now = _utcNow(); ScreenActivityObservation screenActivityObservation = _screenActivityReader.Observe(now); _cadence.ObserveScreen(screenActivityObservation.Visible, screenActivityObservation.OpenedTransition, now); } } private static async Task DelayQuietlyAsync(TimeSpan delay, CancellationToken cancellationToken) { try { await Task.Delay(delay, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } } } internal sealed class BridgeSettingsStore { public const int MaxSettingsBytes = 16384; private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = true }; private static readonly HashSet Version1Properties = new HashSet(StringComparer.Ordinal) { "version", "bridgeToken", "lastSequence", "scope", "phoneRecipesRevision", "advisorDispatchesRevision" }; private static readonly HashSet Version2Properties = new HashSet(StringComparer.Ordinal) { "version", "bridgeToken", "lastSequence", "scope", "phoneRecipesRevision", "advisorDispatchesRevision", "requiresGenerationRePair" }; private static readonly HashSet Version3Properties = new HashSet(StringComparer.Ordinal) { "version", "protectedBridgeToken", "lastSequence", "scope", "phoneRecipesRevision", "advisorDispatchesRevision", "requiresGenerationRePair" }; private static readonly HashSet LegacyProperties = new HashSet(StringComparer.Ordinal) { "bridgeToken", "lastSequence" }; private static readonly HashSet ScopeProperties = new HashSet(StringComparer.Ordinal) { "pairingId", "deviceId", "profileId", "saveFingerprint" }; private static readonly HashSet FingerprintProperties = new HashSet(StringComparer.Ordinal) { "slotId", "steamIdHash", "fingerprint" }; private readonly string _settingsPath; private readonly MelonBridgeLogger _logger; private readonly Action _replaceFile; private readonly IBridgeTokenProtector _tokenProtector; private readonly string? _legacySettingsPath; private readonly Action _deleteFile; internal BridgeSettingsStore(string settingsPath, MelonBridgeLogger logger, Action? replaceFile = null, IBridgeTokenProtector? tokenProtector = null, string? legacySettingsPath = null, Action? deleteFile = null) { _settingsPath = settingsPath; _logger = logger; _replaceFile = replaceFile ?? ((Action)delegate(string source, string destination) { File.Move(source, destination, overwrite: true); }); _tokenProtector = tokenProtector ?? new WindowsBridgeTokenProtector(); _legacySettingsPath = (string.Equals(settingsPath, legacySettingsPath, StringComparison.OrdinalIgnoreCase) ? null : legacySettingsPath); _deleteFile = deleteFile ?? new Action(File.Delete); } public static BridgeSettingsStore Create(MelonBridgeLogger logger) { string path = (string.IsNullOrWhiteSpace(MelonEnvironment.UserDataDirectory) ? Path.Combine(AppContext.BaseDirectory, "UserData") : MelonEnvironment.UserDataDirectory); string text = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); if (string.IsNullOrWhiteSpace(text)) { text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "Local"); } string legacySettingsPath = Path.Combine(path, "HylandHelperBridge", "settings.json"); return new BridgeSettingsStore(BuildProtectedSettingsPath(text), logger, null, null, legacySettingsPath); } internal static string BuildProtectedSettingsPath(string localAppData) { if (string.IsNullOrWhiteSpace(localAppData)) { throw new ArgumentException("Local application data path was unavailable.", "localAppData"); } return Path.Combine(localAppData, "HylandHelper", "Bridge", "settings.json"); } public BridgeLocalSettings Load() { string text = (File.Exists(_settingsPath) ? _settingsPath : ((_legacySettingsPath != null && File.Exists(_legacySettingsPath)) ? _legacySettingsPath : null)); if (text == null) { return new BridgeLocalSettings(); } try { bool requiresRewrite; BridgeLocalSettings bridgeLocalSettings = Read(text, out requiresRewrite); bool flag = !string.Equals(text, _settingsPath, StringComparison.OrdinalIgnoreCase); if ((requiresRewrite || flag) && !Save(bridgeLocalSettings)) { _logger.WarningThrottled("Bridge settings migration will be retried; the prior record was preserved."); } return bridgeLocalSettings; } catch (Exception exception) { _logger.WarningThrottled("Bridge settings could not be read; starting with empty local state.", exception); return new BridgeLocalSettings(); } } private BridgeLocalSettings Read(string path, out bool requiresRewrite) { FileInfo fileInfo = new FileInfo(path); if (fileInfo.Length <= 0 || fileInfo.Length > 16384) { throw new InvalidDataException("Bridge settings length was invalid."); } string text = File.ReadAllText(path); if (Encoding.UTF8.GetByteCount(text) > 16384) { throw new InvalidDataException("Bridge settings exceeded the size limit."); } using JsonDocument jsonDocument = JsonDocument.Parse(text); JsonElement rootElement = jsonDocument.RootElement; if (rootElement.ValueKind != JsonValueKind.Object) { throw new InvalidDataException("Bridge settings must be an object."); } JsonElement value; bool flag = rootElement.TryGetProperty("version", out value); int value2 = 0; if (flag && (value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out value2) || value2 < 1 || value2 > 3)) { throw new InvalidDataException("Bridge settings version was unsupported."); } ValidateObject(rootElement, (!flag) ? LegacyProperties : (value2 switch { 2 => Version2Properties, 1 => Version1Properties, _ => Version3Properties, })); if (rootElement.TryGetProperty("scope", out var value3)) { ValidateObject(value3, ScopeProperties); RequireProperties(value3, "pairingId", "deviceId", "profileId", "saveFingerprint"); JsonElement property = value3.GetProperty("saveFingerprint"); ValidateObject(property, FingerprintProperties); RequireProperties(property, "slotId", "fingerprint"); } BridgeLocalSettings bridgeLocalSettings = JsonSerializer.Deserialize(text, JsonOptions) ?? throw new InvalidDataException("Bridge settings were empty."); requiresRewrite = !flag || value2 < 3; if (!flag || value2 <= 2) { bridgeLocalSettings.BridgeToken = bridgeLocalSettings.LegacyBridgeToken; } else if (bridgeLocalSettings.ProtectedBridgeToken != null) { if (!_tokenProtector.TryUnprotect(bridgeLocalSettings.ProtectedBridgeToken, out string token)) { throw new InvalidDataException("Protected bridge token could not be read."); } bridgeLocalSettings.BridgeToken = token; } if (!flag || value2 == 1) { bridgeLocalSettings.Scope = null; bridgeLocalSettings.PhoneRecipesRevision = null; bridgeLocalSettings.AdvisorDispatchesRevision = null; bridgeLocalSettings.RequiresGenerationRePair = !string.IsNullOrWhiteSpace(bridgeLocalSettings.BridgeToken); } bridgeLocalSettings.LegacyBridgeToken = null; bridgeLocalSettings.Version = 3; if (!bridgeLocalSettings.IsValid()) { throw new InvalidDataException("Bridge settings failed bounded validation."); } return bridgeLocalSettings; } public bool Save(BridgeLocalSettings settings) { string text = null; try { settings.Version = 3; if (!settings.IsValid()) { return false; } settings.LegacyBridgeToken = null; settings.ProtectedBridgeToken = ((settings.BridgeToken == null) ? null : _tokenProtector.Protect(settings.BridgeToken)); if (settings.ProtectedBridgeToken != null && settings.ProtectedBridgeToken.Length > 2048) { return false; } string text2 = JsonSerializer.Serialize(settings, JsonOptions); if (Encoding.UTF8.GetByteCount(text2) > 16384) { return false; } string? directoryName = Path.GetDirectoryName(_settingsPath); Directory.CreateDirectory(directoryName); text = Path.Combine(directoryName, $"{Path.GetFileName(_settingsPath)}.{Guid.NewGuid():N}.tmp"); using (FileStream fileStream = new FileStream(text, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { using StreamWriter streamWriter = new StreamWriter(fileStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); streamWriter.Write(text2); streamWriter.Flush(); fileStream.Flush(flushToDisk: true); } _replaceFile(text, _settingsPath); text = null; DeleteLegacyAfterSuccessfulWrite(); return true; } catch (Exception exception) { _logger.WarningThrottled("Bridge settings could not be saved.", exception); return false; } finally { if (text != null) { try { File.Delete(text); } catch { } } } } private void DeleteLegacyAfterSuccessfulWrite() { if (_legacySettingsPath == null || !File.Exists(_legacySettingsPath)) { return; } try { _deleteFile(_legacySettingsPath); } catch (Exception exception) { _logger.WarningThrottled("Legacy bridge settings could not be removed; cleanup will be retried.", exception); } } public long NextSequence(BridgeLocalSettings settings, DateTimeOffset now) { long num = Math.Max(now.ToUnixTimeMilliseconds(), settings.LastSequence + 1); long lastSequence = settings.LastSequence; settings.LastSequence = num; if (!Save(settings)) { settings.LastSequence = lastSequence; throw new InvalidOperationException("Bridge sequence could not be persisted."); } return num; } private static void ValidateObject(JsonElement element, HashSet allowed) { if (element.ValueKind != JsonValueKind.Object) { throw new InvalidDataException("Bridge settings object was invalid."); } HashSet hashSet = new HashSet(StringComparer.Ordinal); foreach (JsonProperty item in element.EnumerateObject()) { if (!allowed.Contains(item.Name) || !hashSet.Add(item.Name)) { throw new InvalidDataException("Bridge settings properties were ambiguous."); } } } private static void RequireProperties(JsonElement element, params string[] names) { foreach (string propertyName in names) { if (!element.TryGetProperty(propertyName, out var _)) { throw new InvalidDataException("Bridge settings scope was incomplete."); } } } } internal sealed class BridgeLocalSettings { public const int CurrentVersion = 3; [JsonPropertyName("version")] public int Version { get; set; } = 3; [JsonIgnore] public string? BridgeToken { get; set; } [JsonPropertyName("bridgeToken")] public string? LegacyBridgeToken { get; set; } [JsonPropertyName("protectedBridgeToken")] public string? ProtectedBridgeToken { get; set; } [JsonPropertyName("lastSequence")] public long LastSequence { get; set; } [JsonPropertyName("scope")] public PairingScope? Scope { get; set; } [JsonPropertyName("phoneRecipesRevision")] public string? PhoneRecipesRevision { get; set; } [JsonPropertyName("advisorDispatchesRevision")] public string? AdvisorDispatchesRevision { get; set; } [JsonPropertyName("requiresGenerationRePair")] public bool RequiresGenerationRePair { get; set; } [JsonIgnore] public bool RequiresScopeUpgrade { get { if (!string.IsNullOrWhiteSpace(BridgeToken)) { return Scope == null; } return false; } } public void ClearTokenScopedState() { BridgeToken = null; LegacyBridgeToken = null; ProtectedBridgeToken = null; Scope = null; PhoneRecipesRevision = null; AdvisorDispatchesRevision = null; RequiresGenerationRePair = false; } public bool IsValid() { if (Version != 3 || LastSequence < 0) { return false; } if (LegacyBridgeToken != null) { return false; } if (BridgeToken != null && !PairingScope.IsBounded(BridgeToken, 256)) { return false; } if (ProtectedBridgeToken != null && (BridgeToken == null || ProtectedBridgeToken.Length > 2048)) { return false; } if (BridgeToken == null && (Scope != null || PhoneRecipesRevision != null || AdvisorDispatchesRevision != null)) { return false; } if (RequiresGenerationRePair && (BridgeToken == null || Scope != null || PhoneRecipesRevision != null || AdvisorDispatchesRevision != null)) { return false; } if (Scope != null && !Scope.IsValid()) { return false; } if (PhoneRecipesRevision != null && !PairingScope.IsBounded(PhoneRecipesRevision, 128)) { return false; } if (AdvisorDispatchesRevision != null && !PairingScope.IsBounded(AdvisorDispatchesRevision, 128)) { return false; } return true; } } internal sealed class PairingScope { public const int MaxScopeIdLength = 64; public const int MaxFingerprintLength = 128; public const int MaxTokenLength = 256; public const int MaxRevisionLength = 128; [JsonPropertyName("pairingId")] public string PairingId { get; set; } = ""; [JsonPropertyName("deviceId")] public string DeviceId { get; set; } = ""; [JsonPropertyName("profileId")] public string ProfileId { get; set; } = ""; [JsonPropertyName("saveFingerprint")] public BridgeSaveFingerprint SaveFingerprint { get; set; } = new BridgeSaveFingerprint(); public bool IsValid() { if (IsBounded(PairingId, 64) && IsBounded(DeviceId, 64) && IsBounded(ProfileId, 64) && SaveFingerprint != null && IsBounded(SaveFingerprint.SlotId, 64) && (SaveFingerprint.SteamIdHash == null || IsBounded(SaveFingerprint.SteamIdHash, 128))) { return IsBounded(SaveFingerprint.Fingerprint, 128); } return false; } public bool Matches(BridgeSaveFingerprint? other) { if (other != null && string.Equals(SaveFingerprint.SlotId, other.SlotId, StringComparison.Ordinal) && string.Equals(SaveFingerprint.SteamIdHash, other.SteamIdHash, StringComparison.Ordinal)) { return string.Equals(SaveFingerprint.Fingerprint, other.Fingerprint, StringComparison.Ordinal); } return false; } internal static bool IsBounded(string? value, int maxLength) { if (!string.IsNullOrWhiteSpace(value)) { return value.Length <= maxLength; } return false; } } [Flags] internal enum BridgeFullSnapshotReason { None = 0, Load = 1, Pairing = 2, SaveCompleted = 4, SyncNow = 8, SaveFingerprintChanged = 0x10, CapabilityChanged = 0x20, SafetyCadence = 0x40, StaleAcknowledgement = 0x80, RuntimeObservation = 0x100 } internal enum BridgeEnvelopeKind { None, FullSnapshot, Heartbeat } internal enum BridgeSaveObservation { Matched, Fallback, DiagnosticRequired, RepairRequired } internal sealed record BridgeEnvelopePlan(BridgeEnvelopeKind Kind, BridgeFullSnapshotReason Reasons = BridgeFullSnapshotReason.None, bool EnterRepairAfterSend = false) { public static readonly BridgeEnvelopePlan None = new BridgeEnvelopePlan(BridgeEnvelopeKind.None); [CompilerGenerated] private BridgeEnvelopePlan(BridgeEnvelopePlan original) { Kind = original.Kind; Reasons = original.Reasons; EnterRepairAfterSend = original.EnterRepairAfterSend; } } internal sealed class BridgeSnapshotCoordinator { private readonly TimeSpan _exchangeCadence; private readonly TimeSpan _safetyCadence; private readonly IBridgeCadenceAuthority? _adaptive; private DateTimeOffset _nextExchangeAt = DateTimeOffset.MinValue; private DateTimeOffset _nextSafetyAt = DateTimeOffset.MinValue; private BridgeFullSnapshotReason _pendingFull = BridgeFullSnapshotReason.Load; public bool RepairRequired { get; private set; } public BridgeSnapshotCoordinator(TimeSpan exchangeCadence, TimeSpan safetyCadence, IBridgeCadenceAuthority? adaptive = null) { if (exchangeCadence <= TimeSpan.Zero || safetyCadence < exchangeCadence) { throw new ArgumentOutOfRangeException("exchangeCadence"); } _exchangeCadence = exchangeCadence; _safetyCadence = safetyCadence; _adaptive = adaptive; } public void RequestFull(BridgeFullSnapshotReason reason) { if (reason != BridgeFullSnapshotReason.None && !RepairRequired) { _pendingFull |= reason; } } public BridgeSaveObservation ObserveSave(DetectedSave save, BridgeSaveFingerprint pairedFingerprint) { if (save.IsFallback) { return BridgeSaveObservation.Fallback; } if (SameFingerprint(save.Fingerprint, pairedFingerprint)) { return BridgeSaveObservation.Matched; } if (RepairRequired) { return BridgeSaveObservation.RepairRequired; } RepairRequired = true; return BridgeSaveObservation.RepairRequired; } public BridgeEnvelopePlan TakeDue(DateTimeOffset now, BridgeExchangeMode mode) { if (!RepairRequired) { switch (mode) { case BridgeExchangeMode.Repair: break; case BridgeExchangeMode.Legacy: if (_pendingFull != BridgeFullSnapshotReason.None) { return new BridgeEnvelopePlan(BridgeEnvelopeKind.FullSnapshot, _pendingFull); } if (!(now >= _nextExchangeAt)) { return BridgeEnvelopePlan.None; } return new BridgeEnvelopePlan(BridgeEnvelopeKind.FullSnapshot); default: if (_pendingFull != BridgeFullSnapshotReason.None) { bool flag = (_pendingFull & ~(BridgeFullSnapshotReason.SaveCompleted | BridgeFullSnapshotReason.SyncNow | BridgeFullSnapshotReason.RuntimeObservation)) == 0; if (_adaptive == null || (flag ? _adaptive.TryAdmitAdaptiveFull(now) : _adaptive.TryAdmitNonAdaptiveStart(now))) { return Started(new BridgeEnvelopePlan(BridgeEnvelopeKind.FullSnapshot, _pendingFull), flag, now); } if (now >= _nextSafetyAt && _adaptive.TryAdmitNonAdaptiveStart(now)) { return Started(new BridgeEnvelopePlan(BridgeEnvelopeKind.FullSnapshot, _pendingFull | BridgeFullSnapshotReason.SafetyCadence), adaptive: false, now); } return BridgeEnvelopePlan.None; } if (now >= _nextSafetyAt && (_adaptive == null || _adaptive.TryAdmitNonAdaptiveStart(now))) { return Started(new BridgeEnvelopePlan(BridgeEnvelopeKind.FullSnapshot, BridgeFullSnapshotReason.SafetyCadence), adaptive: false, now); } if (mode == BridgeExchangeMode.Exchange) { if (_adaptive == null && now >= _nextExchangeAt) { return new BridgeEnvelopePlan(BridgeEnvelopeKind.Heartbeat); } if (_adaptive != null && _adaptive.TryAdmitHeartbeat(now, out var adaptive)) { return Started(new BridgeEnvelopePlan(BridgeEnvelopeKind.Heartbeat), adaptive, now); } } return BridgeEnvelopePlan.None; } } return BridgeEnvelopePlan.None; } public void Complete(BridgeEnvelopePlan plan, DateTimeOffset now) { if (plan.Kind != BridgeEnvelopeKind.None) { _nextExchangeAt = now + _exchangeCadence; _adaptive?.NotifyCompleted(plan, now); if (plan.Kind == BridgeEnvelopeKind.FullSnapshot) { _pendingFull &= ~plan.Reasons; _nextSafetyAt = now + _safetyCadence; } if (plan.EnterRepairAfterSend) { RepairRequired = true; } } } private BridgeEnvelopePlan Started(BridgeEnvelopePlan plan, bool adaptive, DateTimeOffset now) { _adaptive?.NotifyStarted(plan, adaptive, now); return plan; } private static bool SameFingerprint(BridgeSaveFingerprint left, BridgeSaveFingerprint right) { if (string.Equals(left.SlotId, right.SlotId, StringComparison.Ordinal) && string.Equals(left.SteamIdHash, right.SteamIdHash, StringComparison.Ordinal)) { return string.Equals(left.Fingerprint, right.Fingerprint, StringComparison.Ordinal); } return false; } } internal sealed class BridgeStatusWriter { private readonly MelonBridgeLogger _logger; private readonly string _path; private readonly string _runtimeSession; private string _lastJson = ""; internal string RuntimeSession => _runtimeSession; internal static string DefaultPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "TVGS", "Schedule I", "HylandHelper", "bridge-status.json"); public BridgeStatusWriter(MelonBridgeLogger logger) : this(logger, DefaultPath) { } internal BridgeStatusWriter(MelonBridgeLogger logger, string path, string? runtimeSession = null) { _logger = logger; _path = path; _runtimeSession = (string.IsNullOrWhiteSpace(runtimeSession) ? Guid.NewGuid().ToString("N") : runtimeSession); string path2 = Path.GetDirectoryName(path) ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "TVGS", "Schedule I", "HylandHelper"); try { Directory.CreateDirectory(path2); } catch { } } public void Write(OverlaySnapshot snapshot) { try { string text = Build(snapshot, _runtimeSession); if (!(text == _lastJson)) { WriteAtomic(_path, text); _lastJson = text; } } catch (Exception exception) { _logger.WarningThrottled("Bridge status file write failed.", exception); } } internal static void WriteAtomic(string path, string json) { string text = $"{path}.{Guid.NewGuid():N}.tmp"; try { File.WriteAllText(text, json); File.Move(text, path, overwrite: true); } finally { try { if (File.Exists(text)) { File.Delete(text); } } catch { } } } internal static string Build(OverlaySnapshot s) { return Build(s, null); } internal static string Build(OverlaySnapshot s, string? runtimeSession) { StringBuilder stringBuilder = new StringBuilder(256); stringBuilder.Append('{'); Field(stringBuilder, "status", s.Status); stringBuilder.Append(','); Field(stringBuilder, "pairingCode", s.PairingCode); stringBuilder.Append(','); Field(stringBuilder, "saveLine", s.SaveLine); stringBuilder.Append(','); Field(stringBuilder, "latestLine", s.LatestLine); stringBuilder.Append(','); stringBuilder.Append("\"sequence\":").Append(s.Sequence).Append(','); stringBuilder.Append("\"canUnlink\":").Append(s.CanUnlink ? "true" : "false"); if (runtimeSession != null) { stringBuilder.Append(','); Field(stringBuilder, "runtimeSession", runtimeSession); } if (s.TileChallenge != null) { stringBuilder.Append(",\"tileChallenge\":{"); stringBuilder.Append("\"version\":").Append(s.TileChallenge.Version).Append(','); Field(stringBuilder, "challenge", s.TileChallenge.Challenge); stringBuilder.Append(','); stringBuilder.Append("\"protocolVersion\":").Append(s.TileChallenge.ProtocolVersion); stringBuilder.Append('}'); } stringBuilder.Append('}'); return stringBuilder.ToString(); } private static void Field(StringBuilder sb, string key, string? value) { sb.Append('"').Append(key).Append("\":"); if (value == null) { sb.Append("null"); return; } sb.Append('"'); foreach (char c in value) { if (c == '"' || c == '\\') { sb.Append('\\').Append(c); } else if (c < ' ') { sb.Append(' '); } else { sb.Append(c); } } sb.Append('"'); } } internal interface IBridgeTokenProtector { string Protect(string token); bool TryUnprotect(string protectedToken, out string? token); } internal sealed class WindowsBridgeTokenProtector : IBridgeTokenProtector { public const int MaxProtectedTokenLength = 2048; private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("HylandHelper.BridgeToken.v1"); private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); public string Protect(string token) { if (!OperatingSystem.IsWindows()) { throw new PlatformNotSupportedException("Bridge credential protection requires Windows."); } if (!PairingScope.IsBounded(token, 256)) { throw new ArgumentException("Bridge token was invalid.", "token"); } byte[] bytes = StrictUtf8.GetBytes(token); byte[] array = null; try { array = ProtectedData.Protect(bytes, Entropy, (DataProtectionScope)0); string text = Convert.ToBase64String(array); if (text.Length > 2048) { throw new InvalidOperationException("Protected bridge token exceeded the local limit."); } return text; } finally { CryptographicOperations.ZeroMemory(bytes); if (array != null) { CryptographicOperations.ZeroMemory(array); } } } public bool TryUnprotect(string protectedToken, out string? token) { token = null; if (!OperatingSystem.IsWindows()) { return false; } if (string.IsNullOrWhiteSpace(protectedToken) || protectedToken.Length > 2048) { return false; } byte[] array = null; byte[] array2 = null; try { array = Convert.FromBase64String(protectedToken); array2 = ProtectedData.Unprotect(array, Entropy, (DataProtectionScope)0); string text = StrictUtf8.GetString(array2); if (!PairingScope.IsBounded(text, 256)) { return false; } token = text; return true; } catch (Exception ex) when (((ex is FormatException || ex is CryptographicException || ex is DecoderFallbackException) ? 1 : 0) != 0) { return false; } finally { if (array != null) { CryptographicOperations.ZeroMemory(array); } if (array2 != null) { CryptographicOperations.ZeroMemory(array2); } } } } internal enum DetectedSaveState { Validated, RuntimeUnavailable, GenerationUnavailable } internal sealed record DetectedSave(BridgeSaveFingerprint Fingerprint, string GameVersion, string StatusLine, string? SlotDirectory = null, DetectedSaveState State = DetectedSaveState.Validated) { internal bool IsFallback => State != DetectedSaveState.Validated; internal bool IsPairingEligible => State == DetectedSaveState.Validated; public static DetectedSave Fallback(string slotId, string reason, DetectedSaveState state = DetectedSaveState.RuntimeUnavailable) { return new DetectedSave(new BridgeSaveFingerprint { SlotId = slotId, Fingerprint = ("fallback-" + slotId).PadRight(16, '0') }, "0.4.5f2", reason, null, state); } [CompilerGenerated] private bool PrintMembers(StringBuilder builder) { RuntimeHelpers.EnsureSufficientExecutionStack(); builder.Append("Fingerprint = "); builder.Append(Fingerprint); builder.Append(", GameVersion = "); builder.Append((object?)GameVersion); builder.Append(", StatusLine = "); builder.Append((object?)StatusLine); builder.Append(", SlotDirectory = "); builder.Append((object?)SlotDirectory); builder.Append(", State = "); builder.Append(State.ToString()); return true; } } internal interface IBridgeGameAdapter { DetectedSave DetectCurrentSave(); BridgeEnvelope BuildHeartbeat(DetectedSave save, long sequence); BridgeEnvelope BuildSnapshot(DetectedSave save, long sequence); } internal sealed class GameAdapter : IBridgeGameAdapter { private sealed record SaveCandidate(string SteamDirectory, string SlotDirectory); private sealed record ActiveSaveCache(string SlotDirectory, LocalFileSignature MetadataSignature, LocalFileSignature GameSignature, ProvenSaveIdentity Identity, string GameVersion, DateTimeOffset ValidatedAt); private readonly MelonBridgeLogger _logger; private readonly SaveSnapshotParser _snapshotParser; private readonly Func> _featureProvider; private readonly Func _activeSaveReader; private readonly Func _savesRootProvider; private readonly Func _generationReader; private readonly Func _gameVersionReader; private readonly Func _fileSignatureReader; private readonly Func _utcNow; private ActiveSaveCache? _activeSaveCache; internal Func? SnapshotPostProcessor { get; set; } public GameAdapter(MelonBridgeLogger logger, Func>? featureProvider = null, Func? activeSaveReader = null, Func? savesRootProvider = null, Func? generationReader = null, Func? gameVersionReader = null, Func? fileSignatureReader = null, Func? utcNow = null) { _logger = logger; _featureProvider = featureProvider ?? ((Func>)(() => BridgeFeatureAdvertisement.Current())); _snapshotParser = new SaveSnapshotParser(logger, _featureProvider); _activeSaveReader = activeSaveReader ?? new Func(RuntimeActiveSaveReader.TryRead); _savesRootProvider = savesRootProvider ?? new Func(DefaultSavesRoot); _generationReader = generationReader ?? new Func(ReadGeneration); _gameVersionReader = gameVersionReader ?? new Func(TryReadGameVersion); _fileSignatureReader = fileSignatureReader ?? new Func(ReadFileSignature); _utcNow = utcNow ?? ((Func)(() => DateTimeOffset.UtcNow)); } public DetectedSave DetectCurrentSave() { try { string savesRoot = _savesRootProvider(); RuntimeActiveSave runtimeActiveSave = _activeSaveReader(); if (runtimeActiveSave == null) { return DetectedSave.Fallback("active-save-unavailable", "Waiting for an active loaded save."); } if (!TryResolveActiveSave(savesRoot, runtimeActiveSave.SavePath, out SaveCandidate candidate) || candidate == null) { return DetectedSave.Fallback("active-save-invalid", "Active save identity was invalid."); } if (!TryReadActiveSave(candidate.SlotDirectory, out ProvenSaveIdentity identity, out string gameVersion)) { return DetectedSave.Fallback("generation-unavailable", "Save generation details are not ready. Finish loading, then retry pairing.", DetectedSaveState.GenerationUnavailable); } return new DetectedSave(new BridgeSaveFingerprint { SlotId = identity.SlotId, SteamIdHash = identity.SteamIdHash, Fingerprint = identity.Fingerprint }, gameVersion, "Save: " + identity.SlotId, candidate.SlotDirectory); } catch (Exception exception) { _logger.WarningThrottled("Save detection quiet-failed.", exception); return DetectedSave.Fallback("save-detection-error", "Save detection unavailable."); } } public BridgeEnvelope BuildHeartbeat(DetectedSave save, long sequence) { string text = DateTimeOffset.UtcNow.ToString("O"); return new BridgeEnvelope { EnvelopeType = "heartbeat", SchemaVersion = "1.0.0", ModVersion = "0.1.3", GameVersion = save.GameVersion, MelonLoaderVersion = MelonLoaderVersion(), SaveFingerprint = save.Fingerprint, Sequence = sequence, SentAt = text, Capabilities = BuildProofCapabilities(save), Snapshot = new BridgeSnapshot { CapturedAt = text, Status = "partial", Slices = new List { "presence" }, Warnings = (save.IsFallback ? new List { save.StatusLine } : new List()) } }; } public BridgeEnvelope BuildSnapshot(DetectedSave save, long sequence) { string text = DateTimeOffset.UtcNow.ToString("O"); ParsedSnapshot parsedSnapshot = _snapshotParser.Parse(save, text); Func snapshotPostProcessor = SnapshotPostProcessor; if (snapshotPostProcessor != null) { parsedSnapshot = snapshotPostProcessor(parsedSnapshot); } return new BridgeEnvelope { EnvelopeType = "snapshot", SchemaVersion = "1.0.0", ModVersion = "0.1.3", GameVersion = save.GameVersion, MelonLoaderVersion = MelonLoaderVersion(), SaveFingerprint = save.Fingerprint, Sequence = sequence, SentAt = text, Capabilities = parsedSnapshot.Capabilities, Snapshot = parsedSnapshot.Snapshot }; } private BridgeCapabilities BuildProofCapabilities(DetectedSave save) { BridgeCapabilitySlice value = new BridgeCapabilitySlice { Status = "unavailable", Source = "notSupported", Reason = "Heartbeat envelopes report presence only; full save data arrives in snapshots." }; return new BridgeCapabilities { Slices = new Dictionary { ["presence"] = new BridgeCapabilitySlice { Status = (save.IsFallback ? "degraded" : "present"), Source = "runtime", Reason = (save.IsFallback ? save.StatusLine : null) }, ["profile"] = new BridgeCapabilitySlice { Status = (save.IsFallback ? "degraded" : "partial"), Source = "diskSave", Reason = "Current proof detects a save slot and fingerprint only." }, ["money"] = value, ["rank"] = value, ["time"] = value, ["inventory"] = value, ["properties"] = value, ["products"] = value, ["layout"] = value, ["session"] = value }, Features = _featureProvider() }; } private static string? TryReadGameVersion(string slotDirectory) { foreach (string item in Directory.EnumerateFiles(slotDirectory, "*.json", SearchOption.AllDirectories).Take(40)) { try { using FileStream utf8Json = File.Open(item, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); using JsonDocument jsonDocument = JsonDocument.Parse(utf8Json); if (jsonDocument.RootElement.TryGetProperty("GameVersion", out var value) && value.ValueKind == JsonValueKind.String) { string text = value.GetString(); if (!string.IsNullOrWhiteSpace(text)) { return text; } } } catch { } } return null; } private static string CurrentGameVersion() { return "0.4.5f2"; } private static string DefaultSavesRoot() { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "TVGS", "Schedule I", "Saves"); } private bool TryReadActiveSave(string slotDirectory, out ProvenSaveIdentity? identity, out string gameVersion) { identity = null; gameVersion = CurrentGameVersion(); LocalFileSignature localFileSignature = _fileSignatureReader(Path.Combine(slotDirectory, "Metadata.json")); LocalFileSignature localFileSignature2 = _fileSignatureReader(Path.Combine(slotDirectory, "Game.json")); DateTimeOffset dateTimeOffset = _utcNow(); ActiveSaveCache activeSaveCache = _activeSaveCache; if (activeSaveCache != null && string.Equals(activeSaveCache.SlotDirectory, slotDirectory, StringComparison.OrdinalIgnoreCase) && activeSaveCache.MetadataSignature == localFileSignature && activeSaveCache.GameSignature == localFileSignature2 && dateTimeOffset - activeSaveCache.ValidatedAt < TimeSpan.FromMinutes(5.0)) { identity = activeSaveCache.Identity; gameVersion = activeSaveCache.GameVersion; return true; } ProvenSaveIdentity provenSaveIdentity = _generationReader(slotDirectory); if (provenSaveIdentity == null) { return false; } string text = _gameVersionReader(slotDirectory) ?? CurrentGameVersion(); _activeSaveCache = new ActiveSaveCache(slotDirectory, localFileSignature, localFileSignature2, provenSaveIdentity, text, dateTimeOffset); identity = provenSaveIdentity; gameVersion = text; return true; } private static ProvenSaveIdentity? ReadGeneration(string slotDirectory) { if (!SaveGenerationIdentity.TryCreate(slotDirectory, out ProvenSaveIdentity identity, out string _)) { return null; } return identity; } private static LocalFileSignature ReadFileSignature(string path) { try { FileInfo fileInfo = new FileInfo(path); return fileInfo.Exists ? new LocalFileSignature(Exists: true, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks) : LocalFileSignature.Missing; } catch { return LocalFileSignature.Missing; } } private static bool TryResolveActiveSave(string savesRoot, string activeSavePath, out SaveCandidate? candidate) { candidate = null; if (string.IsNullOrWhiteSpace(savesRoot) || string.IsNullOrWhiteSpace(activeSavePath)) { return false; } string text = Path.GetFullPath(savesRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); string text2 = Path.GetFullPath(activeSavePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (!Directory.Exists(text) || !Directory.Exists(text2)) { return false; } string directoryName = Path.GetDirectoryName(text2); if (string.IsNullOrWhiteSpace(directoryName) || !string.Equals(Path.GetDirectoryName(directoryName), text, StringComparison.OrdinalIgnoreCase)) { return false; } if (RuntimeActiveSaveReader.ExtractSaveSlot(Path.GetFileName(text2)) == null) { return false; } candidate = new SaveCandidate(directoryName, text2); return true; } private static string MelonLoaderVersion() { try { return typeof(MelonMod).Assembly.GetName().Version?.ToString(3) ?? "0.7.3"; } catch { return "0.7.3"; } } } internal enum LiveFactDomain { Rank, ElapsedDays, Properties, Money } internal sealed record RankQuartet(string? Label, int Tier, int Xp, int TotalXp); internal sealed class LiveFactBaseline { private RankQuartet? _rankFloor; private int? _elapsedDaysFloor; private int? _ownedCountFloor; private readonly HashSet _baselinePending = new HashSet(); internal LiveFactBaseline() { NotifyRotation(); } internal void NotifyRotation() { _rankFloor = null; _elapsedDaysFloor = null; _ownedCountFloor = null; _baselinePending.Clear(); _baselinePending.Add(LiveFactDomain.Rank); _baselinePending.Add(LiveFactDomain.ElapsedDays); _baselinePending.Add(LiveFactDomain.Properties); _baselinePending.Add(LiveFactDomain.Money); } internal bool PeekRuntimeBaseline(LiveFactDomain domain) { return _baselinePending.Contains(domain); } internal void CommitRuntimeBaseline(LiveFactDomain domain) { _baselinePending.Remove(domain); } internal (RankQuartet? Emitted, bool UsedRuntimeAuthority) MergeRank(RankQuartet? disk, RankQuartet? runtime) { RankQuartet rankQuartet = null; bool flag = false; if (disk != null) { rankQuartet = disk; } if (_rankFloor != null && (rankQuartet == null || Beats(_rankFloor, rankQuartet))) { rankQuartet = _rankFloor; flag = true; } if (runtime != null && (rankQuartet == null || Beats(runtime, rankQuartet) || Ties(runtime, rankQuartet))) { rankQuartet = runtime; flag = true; } if (rankQuartet != null) { _rankFloor = rankQuartet; } return (Emitted: rankQuartet, UsedRuntimeAuthority: rankQuartet != null && flag); } internal (int? Emitted, bool UsedRuntimeAuthority) MergeElapsedDays(int? disk, int? runtime) { return MergeInt(ref _elapsedDaysFloor, disk, runtime); } internal (int? Emitted, bool UsedRuntimeAuthority) MergeOwnedCount(int? disk, int? runtime) { return MergeInt(ref _ownedCountFloor, disk, runtime); } private static (int?, bool) MergeInt(ref int? floor, int? disk, int? runtime) { int? num = null; bool flag = false; if (disk.HasValue) { num = disk; } if (floor.HasValue && (!num.HasValue || floor.Value > num.Value)) { num = floor; flag = true; } if (runtime.HasValue && (!num.HasValue || runtime.Value >= num.Value)) { num = runtime; flag = true; } if (num.HasValue) { floor = num; } return (num, num.HasValue && flag); } private static bool Beats(RankQuartet a, RankQuartet b) { if (a.TotalXp <= b.TotalXp) { if (a.TotalXp == b.TotalXp) { return a.Tier > b.Tier; } return false; } return true; } private static bool Ties(RankQuartet a, RankQuartet b) { if (a.TotalXp == b.TotalXp) { return a.Tier == b.Tier; } return false; } } internal sealed class LiveFactMerger { internal const string ReasonRuntimeBaseline = "runtimeBaseline"; internal const string ReasonRuntimeCoreDiskRetained = "runtimeCoreDiskRetained"; private readonly LiveFactBaseline _baseline; private readonly List _runtimeBaselineDomains = new List(); internal LiveFactMerger(LiveFactBaseline baseline) { _baseline = baseline; } internal ParsedSnapshot Merge(ParsedSnapshot parsed, LiveOverlayDocument? overlay) { _runtimeBaselineDomains.Clear(); MergeRank(parsed, overlay?.Facts.Rank); MergeTime(parsed, overlay?.Facts.ElapsedDays); MergeProperties(parsed, overlay?.Facts.Properties); MergeMoney(parsed, overlay?.Facts.Money); return parsed; } internal IReadOnlyList TakeRuntimeBaselineDomains() { List result = new List(_runtimeBaselineDomains); _runtimeBaselineDomains.Clear(); return result; } private void MergeRank(ParsedSnapshot parsed, LiveRankFact? runtime) { BridgeRankSnapshot rank = parsed.Snapshot.Rank; if (rank == null) { return; } RankQuartet disk = (rank.Tier.HasValue ? new RankQuartet(rank.Rank, rank.Tier.Value, (int)rank.Xp.GetValueOrDefault(), (int)rank.TotalXp.GetValueOrDefault()) : null); RankQuartet runtime2 = ((runtime == null) ? null : new RankQuartet(RankLabelNormalizer.Normalize(runtime.RankName, runtime.Tier), runtime.Tier, runtime.Xp, runtime.TotalXp)); var (rankQuartet, flag) = _baseline.MergeRank(disk, runtime2); if (!(rankQuartet == null)) { rank.Rank = rankQuartet.Label; rank.Tier = rankQuartet.Tier; rank.Xp = rankQuartet.Xp; rank.TotalXp = rankQuartet.TotalXp; if (flag) { Label(parsed, "rank", LiveFactDomain.Rank); } } } private void MergeTime(ParsedSnapshot parsed, LiveElapsedDaysFact? runtime) { BridgeTimeSnapshot time = parsed.Snapshot.Time; if (time == null) { return; } var (elapsedDays, flag) = _baseline.MergeElapsedDays(time.ElapsedDays, runtime?.Value); if (elapsedDays.HasValue) { time.ElapsedDays = elapsedDays; if (flag) { Label(parsed, "time", LiveFactDomain.ElapsedDays); } } } private void MergeProperties(ParsedSnapshot parsed, LivePropertiesFact? runtime) { BridgePropertiesSnapshot properties = parsed.Snapshot.Properties; if (properties == null) { return; } var (num, flag) = _baseline.MergeOwnedCount(properties.OwnedCount, runtime?.Codes.Count); if (num.HasValue) { properties.OwnedCount = num.Value; if (flag) { Label(parsed, "properties", LiveFactDomain.Properties); } } } private void MergeMoney(ParsedSnapshot parsed, LiveMoneyFact? runtime) { BridgeMoneySnapshot money = parsed.Snapshot.Money; if (money != null && runtime != null) { money.CashOnHandTotal = (decimal)runtime.CashOnHand.Value; money.AppProfileCash = (decimal)runtime.CashOnHand.Value; money.OnlineBalance = (decimal)runtime.OnlineBalance.Value; money.Networth = (decimal)runtime.NetWorth.Value; Label(parsed, "money", LiveFactDomain.Money); if (parsed.Capabilities.Slices.TryGetValue("money", out BridgeCapabilitySlice value)) { value.Status = "present"; } } } private void Label(ParsedSnapshot parsed, string sliceName, LiveFactDomain domain) { if (parsed.Capabilities.Slices.TryGetValue(sliceName, out BridgeCapabilitySlice value)) { value.Source = "runtime"; if (_baseline.PeekRuntimeBaseline(domain)) { value.Reason = "runtimeBaseline"; _runtimeBaselineDomains.Add(domain); } else { value.Reason = "runtimeCoreDiskRetained"; } } } } internal sealed class LiveOverlayDocument { public const int SchemaVersionCurrent = 1; public const int MaxSerializedBytes = 8192; [JsonPropertyName("schemaVersion")] public int SchemaVersion { get; set; } = 1; [JsonPropertyName("playthroughGeneration")] public string PlaythroughGeneration { get; set; } = ""; [JsonPropertyName("runtimeSession")] public string RuntimeSession { get; set; } = ""; [JsonPropertyName("tileInstanceId")] public string TileInstanceId { get; set; } = ""; [JsonPropertyName("loadEpoch")] public int LoadEpoch { get; set; } [JsonPropertyName("sequence")] public long Sequence { get; set; } [JsonPropertyName("observedAt")] public string ObservedAt { get; set; } = ""; [JsonPropertyName("tileVersion")] public string TileVersion { get; set; } = ""; [JsonPropertyName("s1ApiVersion")] public string S1ApiVersion { get; set; } = ""; [JsonPropertyName("facts")] public LiveOverlayFacts Facts { get; set; } = new LiveOverlayFacts(); } internal sealed class LiveOverlayFacts { [JsonPropertyName("rank")] public LiveRankFact? Rank { get; set; } [JsonPropertyName("elapsedDays")] public LiveElapsedDaysFact? ElapsedDays { get; set; } [JsonPropertyName("properties")] public LivePropertiesFact? Properties { get; set; } [JsonPropertyName("money")] public LiveMoneyFact? Money { get; set; } } internal sealed class LiveRankFact { [JsonPropertyName("scope")] public string Scope { get; set; } = "shared"; [JsonPropertyName("rankName")] public string RankName { get; set; } = ""; [JsonPropertyName("tier")] public int Tier { get; set; } [JsonPropertyName("xp")] public int Xp { get; set; } [JsonPropertyName("totalXp")] public int TotalXp { get; set; } } internal sealed class LiveElapsedDaysFact { [JsonPropertyName("scope")] public string Scope { get; set; } = "shared"; [JsonPropertyName("value")] public int Value { get; set; } } internal sealed class LivePropertiesFact { [JsonPropertyName("scope")] public string Scope { get; set; } = "shared"; [JsonPropertyName("codes")] public List Codes { get; set; } = new List(); [JsonPropertyName("businessCodes")] public List BusinessCodes { get; set; } = new List(); } internal sealed class LiveMoneyFact { [JsonPropertyName("cashOnHand")] public LiveMoneyValue CashOnHand { get; set; } = new LiveMoneyValue { Scope = "player" }; [JsonPropertyName("onlineBalance")] public LiveMoneyValue OnlineBalance { get; set; } = new LiveMoneyValue(); [JsonPropertyName("netWorth")] public LiveMoneyValue NetWorth { get; set; } = new LiveMoneyValue(); } internal sealed class LiveMoneyValue { [JsonPropertyName("scope")] public string Scope { get; set; } = "shared"; [JsonPropertyName("value")] public double Value { get; set; } } internal static class LiveOverlaySerializer { private static readonly JsonSerializerOptions Options = new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private static readonly string[] TopLevelKeys = new string[10] { "schemaVersion", "playthroughGeneration", "runtimeSession", "tileInstanceId", "loadEpoch", "sequence", "observedAt", "tileVersion", "s1ApiVersion", "facts" }; private static readonly string[] FactKeys = new string[4] { "rank", "elapsedDays", "properties", "money" }; private static readonly string[] RankKeys = new string[5] { "scope", "rankName", "tier", "xp", "totalXp" }; private static readonly string[] ValueFactKeys = new string[2] { "scope", "value" }; private static readonly string[] PropertiesKeys = new string[3] { "scope", "codes", "businessCodes" }; private static readonly string[] MoneyKeys = new string[3] { "cashOnHand", "onlineBalance", "netWorth" }; internal static bool TrySerialize(LiveOverlayDocument document, out byte[] utf8, out string? reason) { utf8 = JsonSerializer.SerializeToUtf8Bytes(document, Options); if (utf8.Length > 8192) { reason = "overBudget"; utf8 = Array.Empty(); return false; } reason = null; return true; } internal static bool TryParse(byte[] utf8, out LiveOverlayDocument? document, out string reason) { document = null; if (utf8 == null || utf8.Length == 0) { reason = "empty"; return false; } if (utf8.Length > 8192) { reason = "overBudget"; return false; } try { using JsonDocument jsonDocument = JsonDocument.Parse(utf8); JsonElement rootElement = jsonDocument.RootElement; if (rootElement.ValueKind != JsonValueKind.Object) { reason = "rootNotObject"; return false; } if (!CheckExactKeys(rootElement, TopLevelKeys, requireAll: true, out reason)) { return false; } LiveOverlayDocument liveOverlayDocument = new LiveOverlayDocument(); if (!TryInt(rootElement, "schemaVersion", 1, 1, out var value, out reason)) { return false; } liveOverlayDocument.SchemaVersion = value; JsonElement property = rootElement.GetProperty("playthroughGeneration"); if (property.ValueKind != JsonValueKind.String || !SaveGenerationIdentity.IsV2Fingerprint(property.GetString())) { reason = "playthroughGeneration"; return false; } liveOverlayDocument.PlaythroughGeneration = property.GetString(); if (!TryBoundedString(rootElement, "runtimeSession", 1, 64, out string value2, out reason)) { return false; } liveOverlayDocument.RuntimeSession = value2; if (!TryBoundedString(rootElement, "tileInstanceId", 32, 32, out string value3, out reason)) { return false; } if (!IsLowerHex(value3)) { reason = "tileInstanceId"; return false; } liveOverlayDocument.TileInstanceId = value3; if (!TryInt(rootElement, "loadEpoch", 1, 1000000, out var value4, out reason)) { return false; } liveOverlayDocument.LoadEpoch = value4; if (!TryLong(rootElement, "sequence", 1L, 999999999999L, out var value5, out reason)) { return false; } liveOverlayDocument.Sequence = value5; if (!TryBoundedString(rootElement, "observedAt", 1, 40, out string value6, out reason)) { return false; } if (!DateTimeOffset.TryParse(value6, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var _)) { reason = "observedAt"; return false; } liveOverlayDocument.ObservedAt = value6; if (!TryBoundedString(rootElement, "tileVersion", 1, 64, out string value7, out reason)) { return false; } liveOverlayDocument.TileVersion = value7; if (!TryBoundedString(rootElement, "s1ApiVersion", 1, 64, out string value8, out reason)) { return false; } liveOverlayDocument.S1ApiVersion = value8; JsonElement property2 = rootElement.GetProperty("facts"); if (property2.ValueKind != JsonValueKind.Object) { reason = "facts"; return false; } if (!CheckExactKeys(property2, FactKeys, requireAll: false, out reason)) { return false; } if (property2.TryGetProperty("rank", out var value9)) { if (!TryParseRank(value9, out LiveRankFact fact, out reason)) { return false; } liveOverlayDocument.Facts.Rank = fact; } if (property2.TryGetProperty("elapsedDays", out var value10)) { if (!TryParseElapsedDays(value10, out LiveElapsedDaysFact fact2, out reason)) { return false; } liveOverlayDocument.Facts.ElapsedDays = fact2; } if (property2.TryGetProperty("properties", out var value11)) { if (!TryParseProperties(value11, out LivePropertiesFact fact3, out reason)) { return false; } liveOverlayDocument.Facts.Properties = fact3; } if (property2.TryGetProperty("money", out var value12)) { if (!TryParseMoney(value12, out LiveMoneyFact fact4, out reason)) { return false; } liveOverlayDocument.Facts.Money = fact4; } document = liveOverlayDocument; reason = "ok"; return true; } catch (JsonException) { reason = "malformedJson"; return false; } } private static bool TryParseRank(JsonElement element, out LiveRankFact? fact, out string reason) { fact = null; if (element.ValueKind != JsonValueKind.Object || !CheckExactKeys(element, RankKeys, requireAll: true, out reason)) { reason = "rank"; return false; } if (!IsScope(element, "shared")) { reason = "rank.scope"; return false; } if (!TryBoundedString(element, "rankName", 1, 32, out string value, out string reason2)) { reason = "rank.rankName"; return false; } if (!TryInt(element, "tier", 1, 99999, out int value2, out reason2)) { reason = "rank.tier"; return false; } if (!TryInt(element, "xp", 0, 999999999, out int value3, out reason2)) { reason = "rank.xp"; return false; } if (!TryInt(element, "totalXp", 0, 999999999, out int value4, out reason2)) { reason = "rank.totalXp"; return false; } fact = new LiveRankFact { RankName = value, Tier = value2, Xp = value3, TotalXp = value4 }; reason = "ok"; return true; } private static bool TryParseElapsedDays(JsonElement element, out LiveElapsedDaysFact? fact, out string reason) { fact = null; if (element.ValueKind != JsonValueKind.Object || !CheckExactKeys(element, ValueFactKeys, requireAll: true, out reason)) { reason = "elapsedDays"; return false; } if (!IsScope(element, "shared")) { reason = "elapsedDays.scope"; return false; } if (!TryInt(element, "value", 0, 99999, out int value, out string _)) { reason = "elapsedDays.value"; return false; } fact = new LiveElapsedDaysFact { Value = value }; reason = "ok"; return true; } private static bool TryParseProperties(JsonElement element, out LivePropertiesFact? fact, out string reason) { fact = null; if (element.ValueKind != JsonValueKind.Object || !CheckExactKeys(element, PropertiesKeys, requireAll: true, out reason)) { reason = "properties"; return false; } if (!IsScope(element, "shared")) { reason = "properties.scope"; return false; } if (!TryCodes(element, "codes", 64, out HashSet codes)) { reason = "properties.codes"; return false; } if (!TryCodes(element, "businessCodes", 32, out HashSet codes2)) { reason = "properties.businessCodes"; return false; } foreach (string item in codes2) { if (!codes.Contains(item)) { reason = "properties.businessSubset"; return false; } } fact = new LivePropertiesFact { Codes = new List(codes), BusinessCodes = new List(codes2) }; reason = "ok"; return true; } private static bool TryParseMoney(JsonElement element, out LiveMoneyFact? fact, out string reason) { fact = null; if (element.ValueKind != JsonValueKind.Object || !CheckExactKeys(element, MoneyKeys, requireAll: true, out reason)) { reason = "money"; return false; } if (!TryMoneyValue(element, "cashOnHand", "player", 0.0, out LiveMoneyValue value)) { reason = "money.cashOnHand"; return false; } if (!TryMoneyValue(element, "onlineBalance", "shared", 0.0, out LiveMoneyValue value2)) { reason = "money.onlineBalance"; return false; } if (!TryMoneyValue(element, "netWorth", "shared", -1000000000.0, out LiveMoneyValue value3)) { reason = "money.netWorth"; return false; } fact = new LiveMoneyFact { CashOnHand = value, OnlineBalance = value2, NetWorth = value3 }; reason = "ok"; return true; } private static bool TryMoneyValue(JsonElement parent, string name, string scope, double minimum, out LiveMoneyValue? value) { value = null; if (!parent.TryGetProperty(name, out var value2) || value2.ValueKind != JsonValueKind.Object) { return false; } if (!CheckExactKeys(value2, ValueFactKeys, requireAll: true, out string _)) { return false; } if (!IsScope(value2, scope)) { return false; } if (!value2.TryGetProperty("value", out var value3) || value3.ValueKind != JsonValueKind.Number) { return false; } if (!decimal.TryParse(value3.GetRawText(), NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) { return false; } if (result != Math.Round(result, 2)) { return false; } if (result < (decimal)minimum || result >= 1000000000m) { return false; } value = new LiveMoneyValue { Scope = scope, Value = (double)result }; return true; } private static bool TryCodes(JsonElement parent, string name, int max, out HashSet codes) { codes = new HashSet(StringComparer.Ordinal); if (!parent.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array) { return false; } int num = 0; foreach (JsonElement item in value.EnumerateArray()) { if (++num > max) { return false; } if (item.ValueKind != JsonValueKind.String) { return false; } string text = item.GetString(); if (string.IsNullOrEmpty(text) || text.Length > 48 || !IsCanonicalCode(text)) { return false; } if (!codes.Add(text)) { return false; } } return true; } internal static bool IsCanonicalCode(string code) { for (int i = 0; i < code.Length; i++) { bool flag; switch (code[i]) { case '-': case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': case '_': case 'a': case 'b': case 'c': case 'd': case 'e': case 'f': case 'g': case 'h': case 'i': case 'j': case 'k': case 'l': case 'm': case 'n': case 'o': case 'p': case 'q': case 'r': case 's': case 't': case 'u': case 'v': case 'w': case 'x': case 'y': case 'z': flag = true; break; default: flag = false; break; } if (!flag) { return false; } } return true; } private static bool IsLowerHex(string value) { for (int i = 0; i < value.Length; i++) { bool flag; switch (value[i]) { case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': case 'a': case 'b': case 'c': case 'd': case 'e': case 'f': flag = true; break; default: flag = false; break; } if (!flag) { return false; } } return true; } private static bool IsScope(JsonElement element, string expected) { if (element.TryGetProperty("scope", out var value) && value.ValueKind == JsonValueKind.String) { return value.GetString() == expected; } return false; } private static bool CheckExactKeys(JsonElement element, string[] allowed, bool requireAll, out string reason) { HashSet hashSet = new HashSet(StringComparer.Ordinal); foreach (JsonProperty item in element.EnumerateObject()) { if (Array.IndexOf(allowed, item.Name) < 0) { reason = "unknownField:" + Bound(item.Name); return false; } if (!hashSet.Add(item.Name)) { reason = "duplicateField:" + Bound(item.Name); return false; } } if (requireAll) { foreach (string text in allowed) { if (!hashSet.Contains(text)) { reason = "missingField:" + text; return false; } } } reason = "ok"; return true; } private static string Bound(string value) { if (value.Length > 32) { return value.Substring(0, 32); } return value; } private static bool TryBoundedString(JsonElement parent, string name, int minLength, int maxLength, out string value, out string reason) { value = ""; if (!parent.TryGetProperty(name, out var value2) || value2.ValueKind != JsonValueKind.String) { reason = name; return false; } string text = value2.GetString() ?? ""; if (text.Trim().Length == 0 || text.Length < minLength || text.Length > maxLength) { reason = name; return false; } value = text; reason = "ok"; return true; } private static bool TryInt(JsonElement parent, string name, int minimum, int maximum, out int value, out string reason) { value = 0; if (!parent.TryGetProperty(name, out var value2) || value2.ValueKind != JsonValueKind.Number || !value2.TryGetInt32(out var value3) || value3 < minimum || value3 > maximum) { reason = name; return false; } value = value3; reason = "ok"; return true; } private static bool TryLong(JsonElement parent, string name, long minimum, long maximum, out long value, out string reason) { value = 0L; if (!parent.TryGetProperty(name, out var value2) || value2.ValueKind != JsonValueKind.Number || !value2.TryGetInt64(out var value3) || value3 < minimum || value3 > maximum) { reason = name; return false; } value = value3; reason = "ok"; return true; } } internal enum LiveOverlayReadStatus { None, Accepted, Rejected } internal sealed record LiveOverlayReadResult(LiveOverlayReadStatus Status, bool Rotation, LiveOverlayDocument? Document, string? Reason); internal sealed class LiveOverlayReader { internal static readonly TimeSpan AdmissionMaxAge = TimeSpan.FromSeconds(90.0); internal static readonly TimeSpan AdmissionMaxFutureSkew = TimeSpan.FromSeconds(10.0); internal static readonly TimeSpan RetentionMaxAge = TimeSpan.FromSeconds(90.0); internal const int ReplayLruCapacity = 8; private readonly string _path; private readonly Func _expectedGeneration; private readonly Func _expectedSession; private readonly Func _signatureReader; private readonly Func _readBytes; private readonly LocalFileRefreshGate _gate; private string? _stateSession; private string? _currentInstance; private int _currentEpoch; private long _lastSequence; private readonly LinkedList _acceptedPairs = new LinkedList(); private LiveOverlayDocument? _current; private DateTimeOffset _acceptedAt; internal static string DefaultPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "TVGS", "Schedule I", "HylandHelper", "live-context-overlay.json"); internal LiveOverlayReader(Func expectedGeneration, Func expectedSession, string? path = null, Func? signatureReader = null, Func? readBytes = null, TimeSpan? forcedRecovery = null) { _path = path ?? DefaultPath; _expectedGeneration = expectedGeneration; _expectedSession = expectedSession; _signatureReader = signatureReader ?? new Func(ReadSignature); _readBytes = readBytes ?? new Func(File.ReadAllBytes); _gate = new LocalFileRefreshGate(forcedRecovery ?? TimeSpan.FromSeconds(15.0)); } internal LiveOverlayReadResult ReadIfDue(DateTimeOffset now, bool dirty) { LocalFileSignature signature = _signatureReader(_path); if (!_gate.ShouldRefresh(signature, now, dirty, out var _)) { return new LiveOverlayReadResult(LiveOverlayReadStatus.None, Rotation: false, null, null); } if (!signature.Exists || signature.Length <= 0 || signature.Length > 8192) { return Reject("signature"); } byte[] utf; try { utf = _readBytes(_path); } catch { return Reject("unreadable"); } if (!LiveOverlaySerializer.TryParse(utf, out LiveOverlayDocument document, out string reason2)) { return Reject(reason2); } LiveOverlayReadResult liveOverlayReadResult = Admit(document, now); if (liveOverlayReadResult.Status == LiveOverlayReadStatus.Accepted) { _gate.Accept(signature, now); return liveOverlayReadResult; } _gate.RejectTransient(); return liveOverlayReadResult; } internal bool TryGetCurrent(DateTimeOffset now, out LiveOverlayDocument document) { document = null; if (_current == null) { return false; } if (now - _acceptedAt > RetentionMaxAge) { return false; } if (_current.PlaythroughGeneration != _expectedGeneration()) { return false; } if (_current.RuntimeSession != _expectedSession()) { return false; } document = _current; return true; } internal void Invalidate() { _current = null; _stateSession = null; _currentInstance = null; _currentEpoch = 0; _lastSequence = 0L; _acceptedPairs.Clear(); } private LiveOverlayReadResult Admit(LiveOverlayDocument document, DateTimeOffset now) { if (document.PlaythroughGeneration != _expectedGeneration()) { return Reject("generationMismatch"); } if (document.RuntimeSession != _expectedSession()) { return Reject("sessionMismatch"); } if (!DateTimeOffset.TryParse(document.ObservedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result)) { return Reject("observedAt"); } if (result < now - AdmissionMaxAge) { return Reject("admissionStale"); } if (result > now + AdmissionMaxFutureSkew) { return Reject("admissionFuture"); } if (document.Facts.Rank != null && RankLabelNormalizer.Normalize(document.Facts.Rank.RankName, document.Facts.Rank.Tier) == null) { return Reject("rankName"); } if (!string.Equals(_stateSession, document.RuntimeSession, StringComparison.Ordinal)) { _stateSession = document.RuntimeSession; _currentInstance = null; _currentEpoch = 0; _lastSequence = 0L; _acceptedPairs.Clear(); } bool rotation; if (string.Equals(document.TileInstanceId, _currentInstance, StringComparison.Ordinal)) { if (document.LoadEpoch < _currentEpoch) { return Reject("epochReplay"); } if (document.LoadEpoch == _currentEpoch) { if (document.Sequence <= _lastSequence) { return Reject("sequenceReplay"); } rotation = false; } else { rotation = true; } } else { if (WasAccepted(document.TileInstanceId, document.LoadEpoch) || IsKnownNonCurrentInstance(document.TileInstanceId)) { return Reject("instanceReplay"); } rotation = true; } _currentInstance = document.TileInstanceId; _currentEpoch = document.LoadEpoch; _lastSequence = document.Sequence; Remember(document.TileInstanceId, document.LoadEpoch); _current = document; _acceptedAt = now; return new LiveOverlayReadResult(LiveOverlayReadStatus.Accepted, rotation, document, null); } private static LiveOverlayReadResult Reject(string reason) { return new LiveOverlayReadResult(LiveOverlayReadStatus.Rejected, Rotation: false, null, reason); } private bool WasAccepted(string instance, int epoch) { return _acceptedPairs.Contains(Pair(instance, epoch)); } private bool IsKnownNonCurrentInstance(string instance) { foreach (string acceptedPair in _acceptedPairs) { if (acceptedPair.StartsWith(instance + ":", StringComparison.Ordinal)) { return true; } } return false; } private void Remember(string instance, int epoch) { string value = Pair(instance, epoch); _acceptedPairs.Remove(value); _acceptedPairs.AddLast(value); while (_acceptedPairs.Count > 8) { _acceptedPairs.RemoveFirst(); } } private static string Pair(string instance, int epoch) { return $"{instance}:{epoch}"; } private static LocalFileSignature ReadSignature(string path) { try { FileInfo fileInfo = new FileInfo(path); return fileInfo.Exists ? new LocalFileSignature(Exists: true, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks) : LocalFileSignature.Missing; } catch { return LocalFileSignature.Missing; } } } [Flags] internal enum LocalRefreshSurface { None = 0, ActiveSave = 1, MyMixes = 2, Status = 4, AdvisorDispatches = 8, PendingCommandResult = 0x10, LiveContext = 0x20 } internal readonly record struct LocalFileSignature(bool Exists, long Length, long LastWriteUtcTicks) { internal static LocalFileSignature Missing => new LocalFileSignature(Exists: false, 0L, 0L); } internal enum LocalRefreshReason { Initial, Dirty, SignatureChanged, ForcedRecovery, Unchanged } internal interface ILocalRefreshInvalidationSource { LocalRefreshSurface Pending { get; } event Action? Invalidated; } internal sealed class LocalRefreshInvalidationHub : ILocalRefreshInvalidationSource { public LocalRefreshSurface Pending { get; private set; } public event Action? Invalidated; public void Invalidate(LocalRefreshSurface surfaces) { if (surfaces != LocalRefreshSurface.None) { LocalRefreshSurface localRefreshSurface = surfaces & ~Pending; Pending |= surfaces; if (localRefreshSurface != LocalRefreshSurface.None) { this.Invalidated?.Invoke(localRefreshSurface); } } } public bool Take(LocalRefreshSurface surface) { if ((Pending & surface) == 0) { return false; } Pending &= ~surface; return true; } public void Reset(LocalRefreshSurface surfaces) { Pending &= ~surfaces; } } internal sealed class LocalFileRefreshGate { private readonly TimeSpan _forcedRecoveryInterval; private LocalFileSignature _lastAccepted; private DateTimeOffset _lastAcceptedAt; private bool _hasAccepted; private bool _retryTransient; public LocalFileRefreshGate(TimeSpan forcedRecoveryInterval) { if (forcedRecoveryInterval <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException("forcedRecoveryInterval"); } _forcedRecoveryInterval = forcedRecoveryInterval; } public bool ShouldRefresh(LocalFileSignature signature, DateTimeOffset now, bool dirty, out LocalRefreshReason reason) { if (!_hasAccepted) { reason = LocalRefreshReason.Initial; return true; } if (dirty || _retryTransient) { reason = LocalRefreshReason.Dirty; return true; } if (signature != _lastAccepted) { reason = LocalRefreshReason.SignatureChanged; return true; } if (now - _lastAcceptedAt >= _forcedRecoveryInterval) { reason = LocalRefreshReason.ForcedRecovery; return true; } reason = LocalRefreshReason.Unchanged; return false; } public void Accept(LocalFileSignature signature, DateTimeOffset now) { _lastAccepted = signature; _lastAcceptedAt = now; _hasAccepted = true; _retryTransient = false; } public void RejectTransient() { _retryTransient = true; } public void Reset() { _lastAccepted = default(LocalFileSignature); _lastAcceptedAt = default(DateTimeOffset); _hasAccepted = false; _retryTransient = false; } } internal sealed class MelonBridgeLogger { private readonly Instance _logger; private readonly Action? _sink; private readonly Dictionary _nextWarningAtByMessage = new Dictionary(); internal MelonBridgeLogger(Instance logger, Action? sink = null) { _logger = logger; _sink = sink; } public void Info(string message) { _sink?.Invoke(message); try { _logger.Msg(message); } catch { } } public void WarningThrottled(string message, Exception? exception = null) { DateTimeOffset utcNow = DateTimeOffset.UtcNow; if (_nextWarningAtByMessage.TryGetValue(message, out var value) && utcNow < value) { return; } _nextWarningAtByMessage[message] = utcNow.AddSeconds(30.0); _sink?.Invoke((exception == null) ? message : (message + " " + exception.Message)); try { _logger.Warning((exception == null) ? message : (message + " " + exception.Message)); } catch { } } } internal sealed class MyMixesWriter { private sealed record RecipeRow(string Product, string Mixer, string Output, int Index); private sealed record ResolvedRow(string Ingredient, string Predecessor, string OutputProductId); private sealed record CreatedProduct(string? Name, List Effects, bool EffectsAvailable); private sealed record ReconstructedMix(string ProductId, string? GameName, string Base, List Steps); private sealed record MixStep(string IngredientId, string OutputProductId, bool EffectsAvailable, List OutputEffects); private sealed class MixesRefreshState { public string SaveFingerprint { get; } public LocalFileSignature Signature { get; } public DateTimeOffset LastParsedAt { get; } public bool Dirty { get; set; } public MixesRefreshState(string saveFingerprint, LocalFileSignature signature, DateTimeOffset lastParsedAt) { SaveFingerprint = saveFingerprint; Signature = signature; LastParsedAt = lastParsedAt; } } private static readonly string[] CreatedProductCollections = new string[4] { "CreatedWeed", "CreatedMeth", "CreatedCocaine", "CreatedShrooms" }; private readonly MelonBridgeLogger _logger; private string _directory; private readonly Dictionary _lastJsonByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _refreshByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Func _fileSignatureReader; private readonly Func _openRead; private readonly Func _utcNow; public MyMixesWriter(MelonBridgeLogger logger) : this(logger, DefaultDirectory(), null, null, null) { } internal MyMixesWriter(MelonBridgeLogger logger, string directory, Func? fileSignatureReader, Func? openRead, Func? utcNow) { _logger = logger; _directory = directory; _fileSignatureReader = fileSignatureReader ?? new Func(ReadFileSignature); _openRead = openRead ?? ((Func)((string path) => new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))); _utcNow = utcNow ?? ((Func)(() => DateTimeOffset.UtcNow)); try { Directory.CreateDirectory(directory); } catch { } } public static string BuildScopedPath(string directory, string saveFingerprint) { if (!SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint)) { throw new ArgumentException("Save fingerprint is not a safe discovered-mix scope.", "saveFingerprint"); } return Path.Combine(directory, "my-mixes", saveFingerprint + ".json"); } public void Write(DetectedSave save) { if (save.IsPairingEligible) { WriteSlot(save.SlotDirectory, save.Fingerprint.Fingerprint); } } internal void Invalidate(string saveFingerprint) { if (!SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint)) { return; } foreach (KeyValuePair item in _refreshByPath) { if (string.Equals(item.Value.SaveFingerprint, saveFingerprint, StringComparison.Ordinal)) { item.Value.Dirty = true; } } } private void WriteSlot(string? slotDirectory, string saveFingerprint) { try { if (string.IsNullOrWhiteSpace(slotDirectory)) { return; } string text = Path.Combine(slotDirectory, "Products.json"); LocalFileSignature localFileSignature = _fileSignatureReader(text); DateTimeOffset dateTimeOffset = _utcNow(); if ((_refreshByPath.TryGetValue(text, out MixesRefreshState value) && value.Signature == localFileSignature && !value.Dirty && dateTimeOffset - value.LastParsedAt < TimeSpan.FromMinutes(5.0)) || !localFileSignature.Exists) { return; } Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); List list = new List(); Dictionary createdProducts; using (Stream utf8Json = _openRead(text)) { using JsonDocument jsonDocument = JsonDocument.Parse(utf8Json); JsonElement jsonElement = jsonDocument.RootElement; if (!jsonElement.TryGetProperty("MixRecipes", out var _) && jsonElement.TryGetProperty("ProductManagerData", out var value3) && value3.ValueKind == JsonValueKind.Object) { jsonElement = value3; } if (!jsonElement.TryGetProperty("MixRecipes", out var value4) || value4.ValueKind != JsonValueKind.Array) { return; } int num = 0; foreach (JsonElement item in value4.EnumerateArray()) { string str = GetStr(item, "Product"); string str2 = GetStr(item, "Mixer"); string str3 = GetStr(item, "Output"); if (!string.IsNullOrEmpty(str) && !string.IsNullOrEmpty(str2) && !string.IsNullOrEmpty(str3) && !string.Equals(str3, str, StringComparison.OrdinalIgnoreCase) && !string.Equals(str3, str2, StringComparison.OrdinalIgnoreCase)) { if (!dictionary.ContainsKey(str3)) { list.Add(str3); } dictionary[str3] = new RecipeRow(str, str2, str3, num++); } } createdProducts = ReadCreatedProducts(jsonDocument.RootElement); } _refreshByPath[text] = new MixesRefreshState(saveFingerprint, localFileSignature, dateTimeOffset); if (dictionary.Count == 0) { return; } List list2 = new List(); foreach (string item2 in list) { list2.Add(Reconstruct(item2, dictionary, createdProducts)); } string text2 = BuildJson(list2); string text3 = BuildScopedPath(_directory, saveFingerprint); if (!_lastJsonByPath.TryGetValue(text3, out string value5) || !(text2 == value5)) { string directoryName = Path.GetDirectoryName(text3); if (!string.IsNullOrWhiteSpace(directoryName)) { Directory.CreateDirectory(directoryName); } File.WriteAllText(text3, text2); _lastJsonByPath[text3] = text2; } } catch (Exception exception) { _logger.WarningThrottled("My-mixes reconstruction failed.", exception); } } private static ReconstructedMix Reconstruct(string finalProduct, Dictionary byOutput, Dictionary createdProducts) { List list = new List(); string text = finalProduct; HashSet hashSet = new HashSet(StringComparer.OrdinalIgnoreCase); RecipeRow value; while (byOutput.TryGetValue(text, out value) && hashSet.Add(text)) { ResolvedRow resolvedRow = ResolveRow(value, byOutput); list.Add(resolvedRow); text = resolvedRow.Predecessor; } list.Reverse(); List steps = list.Select(delegate(ResolvedRow row) { createdProducts.TryGetValue(row.OutputProductId, out CreatedProduct value3); return new MixStep(GameDatasetIds.Canonicalize(row.Ingredient), row.OutputProductId, value3?.EffectsAvailable ?? false, (value3?.Effects == null) ? new List() : new List(value3.Effects)); }).ToList(); createdProducts.TryGetValue(finalProduct, out CreatedProduct value2); return new ReconstructedMix(finalProduct, value2?.Name, GameDatasetIds.Canonicalize(text), steps); } private static ResolvedRow ResolveRow(RecipeRow row, Dictionary byOutput) { bool flag = GameDatasetIds.IsBaseProduct(row.Product); bool flag2 = GameDatasetIds.IsBaseProduct(row.Mixer); if (flag != flag2) { if (!flag) { return new ResolvedRow(row.Product, row.Mixer, row.Output); } return new ResolvedRow(row.Mixer, row.Product, row.Output); } bool flag3 = byOutput.ContainsKey(row.Product); bool flag4 = byOutput.ContainsKey(row.Mixer); if (flag3 != flag4) { if (!flag3) { return new ResolvedRow(row.Product, row.Mixer, row.Output); } return new ResolvedRow(row.Mixer, row.Product, row.Output); } if (flag3 && flag4) { bool flag5 = byOutput[row.Product].Index < row.Index; bool flag6 = byOutput[row.Mixer].Index < row.Index; if (flag5 != flag6) { if (!flag5) { return new ResolvedRow(row.Product, row.Mixer, row.Output); } return new ResolvedRow(row.Mixer, row.Product, row.Output); } } return new ResolvedRow(row.Product, row.Mixer, row.Output); } private static Dictionary ReadCreatedProducts(JsonElement root) { Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); JsonElement jsonElement = root; if (root.TryGetProperty("ProductManagerData", out var value) && value.ValueKind == JsonValueKind.Object) { jsonElement = value; } string[] createdProductCollections = CreatedProductCollections; foreach (string propertyName in createdProductCollections) { if (!jsonElement.TryGetProperty(propertyName, out var value2) || value2.ValueKind != JsonValueKind.Array) { continue; } foreach (JsonElement item in value2.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object) { continue; } string firstStr = GetFirstStr(item, "ID", "Id", "ProductID", "ProductId"); if (string.IsNullOrEmpty(firstStr)) { continue; } List list = new List(); JsonElement value3; bool flag = item.TryGetProperty("Properties", out value3) && value3.ValueKind == JsonValueKind.Array; if (flag) { foreach (JsonElement item2 in value3.EnumerateArray()) { if (item2.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(item2.GetString()) || item2.GetString().Length > 64) { flag = false; } else { list.Add(item2.GetString()); } } if (list.Count > 12) { flag = false; } } dictionary[firstStr] = new CreatedProduct(GetFirstStr(item, "Name", "ProductName"), list, flag); } } return dictionary; } private static string BuildJson(List mixes) { StringBuilder stringBuilder = new StringBuilder(1024); stringBuilder.Append("{\"mixes\":["); for (int i = 0; i < mixes.Count; i++) { if (i > 0) { stringBuilder.Append(','); } ReconstructedMix reconstructedMix = mixes[i]; stringBuilder.Append('{'); Field(stringBuilder, "name", reconstructedMix.ProductId); stringBuilder.Append(','); Field(stringBuilder, "productId", reconstructedMix.Base); stringBuilder.Append(','); Field(stringBuilder, "gameName", reconstructedMix.GameName); stringBuilder.Append(','); Field(stringBuilder, "base", reconstructedMix.Base); stringBuilder.Append(','); stringBuilder.Append("\"ingredients\":["); for (int j = 0; j < reconstructedMix.Steps.Count; j++) { if (j > 0) { stringBuilder.Append(','); } Str(stringBuilder, reconstructedMix.Steps[j].IngredientId); } stringBuilder.Append("],\"steps\":["); for (int k = 0; k < reconstructedMix.Steps.Count; k++) { if (k > 0) { stringBuilder.Append(','); } MixStep mixStep = reconstructedMix.Steps[k]; stringBuilder.Append('{'); Field(stringBuilder, "ingredientId", mixStep.IngredientId); stringBuilder.Append(','); Field(stringBuilder, "outputProductId", mixStep.OutputProductId); stringBuilder.Append(','); stringBuilder.Append("\"effectsAvailable\":").Append(mixStep.EffectsAvailable ? "true" : "false"); stringBuilder.Append(",\"outputEffects\":["); for (int l = 0; l < mixStep.OutputEffects.Count; l++) { if (l > 0) { stringBuilder.Append(','); } Str(stringBuilder, mixStep.OutputEffects[l]); } stringBuilder.Append(']'); stringBuilder.Append('}'); } stringBuilder.Append("]}"); } stringBuilder.Append("]}"); return stringBuilder.ToString(); } private static string? GetFirstStr(JsonElement e, params string[] properties) { foreach (string property in properties) { string str = GetStr(e, property); if (!string.IsNullOrEmpty(str)) { return str; } } return null; } private static string? GetStr(JsonElement e, string property) { if (!e.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.String) { return null; } return value.GetString(); } private static string DefaultDirectory() { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "TVGS", "Schedule I", "HylandHelper"); } private static LocalFileSignature ReadFileSignature(string path) { try { FileInfo fileInfo = new FileInfo(path); return fileInfo.Exists ? new LocalFileSignature(Exists: true, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks) : LocalFileSignature.Missing; } catch { return LocalFileSignature.Missing; } } private static void Field(StringBuilder sb, string key, string? value) { Str(sb, key); sb.Append(':'); Str(sb, value); } private static void Str(StringBuilder sb, string? value) { if (value == null) { sb.Append("null"); } else { sb.Append(JsonSerializer.Serialize(value)); } } } internal sealed class OverlayState { private readonly object _gate = new object(); private string _status = "Bridge idle."; public string? PairingCode { get; set; } public string SaveLine { get; set; } = "Save: detecting..."; public string LatestLine { get; set; } = "Latest: pending"; public long Sequence { get; set; } public TileChallengeStatus? TileChallenge { get; set; } public bool CanUnlink { get; set; } public void SetStatus(string value) { lock (_gate) { _status = value; } } public OverlaySnapshot Snapshot() { lock (_gate) { return new OverlaySnapshot(_status, PairingCode, SaveLine, LatestLine, Sequence, TileChallenge, CanUnlink); } } } internal sealed record OverlaySnapshot(string Status, string? PairingCode, string SaveLine, string LatestLine, long Sequence, TileChallengeStatus? TileChallenge = null, bool CanUnlink = false); internal static class OverlayRenderer { public static void Draw(OverlayState state) { //IL_0031: Unknown result type (might be due to invalid IL or missing references) //IL_0056: Unknown result type (might be due to invalid IL or missing references) //IL_00bd: Unknown result type (might be due to invalid IL or missing references) //IL_008b: Unknown result type (might be due to invalid IL or missing references) //IL_0121: Unknown result type (might be due to invalid IL or missing references) //IL_0144: Unknown result type (might be due to invalid IL or missing references) OverlaySnapshot overlaySnapshot = state.Snapshot(); float num = (string.IsNullOrWhiteSpace(overlaySnapshot.PairingCode) ? 120f : 150f); GUI.Box(new Rect(12f, 12f, 380f, num), "Hyland Helper Bridge"); float num2 = 40f; GUI.Label(new Rect(24f, num2, 356f, 22f), overlaySnapshot.Status); num2 += 24f; if (!string.IsNullOrWhiteSpace(overlaySnapshot.PairingCode)) { GUI.Label(new Rect(24f, num2, 356f, 22f), "Pairing code: " + overlaySnapshot.PairingCode); num2 += 24f; } GUI.Label(new Rect(24f, num2, 356f, 22f), overlaySnapshot.SaveLine); num2 += 24f; string text = ((overlaySnapshot.Sequence > 0) ? $"Last snapshot sequence: {overlaySnapshot.Sequence}" : "Last snapshot sequence: pending"); GUI.Label(new Rect(24f, num2, 356f, 22f), text); num2 += 24f; GUI.Label(new Rect(24f, num2, 356f, 22f), overlaySnapshot.LatestLine); } } public enum PhoneRecipeCacheState { Empty, Fresh, Offline, Stale, Expired } public sealed class PhoneRecipeCacheRead { public PhoneRecipeCacheState State { get; init; } public BridgePhoneRecipeSnapshot? Snapshot { get; init; } public DateTimeOffset? FetchedAt { get; init; } public DateTimeOffset? OfflineSince { get; init; } } internal sealed class PhoneRecipeCache { private sealed class PhoneRecipeCacheDocument { [JsonPropertyName("version")] public int Version { get; set; } [JsonPropertyName("saveFingerprint")] public string SaveFingerprint { get; set; } = ""; [JsonPropertyName("snapshot")] public BridgePhoneRecipeSnapshot? Snapshot { get; set; } [JsonPropertyName("fetchedAt")] public DateTimeOffset FetchedAt { get; set; } [JsonPropertyName("offlineSince")] public DateTimeOffset? OfflineSince { get; set; } } private const int MaxCacheBytes = 524288; private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, WriteIndented = true }; private readonly string _path; private readonly Action _replaceFile; private readonly Action _deleteFile; public static string DefaultPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "TVGS", "Schedule I", "HylandHelper", "phone-recipes-cache.json"); public PhoneRecipeCache(string path, Action? replaceFile = null, Action? deleteFile = null) { _path = path; _replaceFile = replaceFile ?? ((Action)delegate(string source, string destination) { File.Move(source, destination, overwrite: true); }); _deleteFile = deleteFile ?? new Action(File.Delete); } public bool Write(BridgePhoneRecipeSnapshot snapshot, DateTimeOffset fetchedAt, string saveFingerprint) { try { if (!SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint) || !IsSafeSnapshot(snapshot)) { return false; } PhoneRecipeCacheDocument value = new PhoneRecipeCacheDocument { Version = 2, SaveFingerprint = saveFingerprint, Snapshot = snapshot, FetchedAt = fetchedAt, OfflineSince = null }; return WriteRawAtomic(JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions)); } catch { return false; } } public bool MarkOffline(DateTimeOffset offlineSince, string saveFingerprint) { try { if (!SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint) || !TryReadDocument(out PhoneRecipeCacheDocument document) || !string.Equals(document.SaveFingerprint, saveFingerprint, StringComparison.Ordinal) || document.Snapshot == null || !IsSafeSnapshot(document.Snapshot)) { return false; } if (document.OfflineSince.HasValue) { return true; } document.OfflineSince = offlineSince; return WriteRawAtomic(JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions)); } catch { return false; } } public PhoneRecipeCacheRead Read(DateTimeOffset now, string saveFingerprint) { try { if (!SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint) || !TryReadDocument(out PhoneRecipeCacheDocument document) || !string.Equals(document.SaveFingerprint, saveFingerprint, StringComparison.Ordinal) || document.Snapshot == null || !IsSafeSnapshot(document.Snapshot) || !DateTimeOffset.TryParse(document.Snapshot.ExpiresAt, out var result)) { return Empty(); } document.Snapshot.Recipes = document.Snapshot.Recipes?.Where((BridgePhoneRecipe recipe) => recipe != null && DateTimeOffset.TryParse(recipe.ExpiresAt, out var result2) && now <= result2).ToList() ?? new List(); if (document.Snapshot.Recipes.Count == 0) { if (now <= result) { return new PhoneRecipeCacheRead { State = PhoneRecipeCacheState.Fresh, Snapshot = document.Snapshot, FetchedAt = document.FetchedAt, OfflineSince = document.OfflineSince }; } if (now <= result.AddHours(24.0)) { return new PhoneRecipeCacheRead { State = PhoneRecipeCacheState.Stale, Snapshot = document.Snapshot, FetchedAt = document.FetchedAt, OfflineSince = document.OfflineSince }; } return new PhoneRecipeCacheRead { State = PhoneRecipeCacheState.Expired, FetchedAt = document.FetchedAt }; } if (document.OfflineSince.HasValue && now >= document.OfflineSince.Value && now <= result) { return new PhoneRecipeCacheRead { State = PhoneRecipeCacheState.Offline, Snapshot = document.Snapshot, FetchedAt = document.FetchedAt, OfflineSince = document.OfflineSince }; } if (now <= result) { return new PhoneRecipeCacheRead { State = PhoneRecipeCacheState.Fresh, Snapshot = document.Snapshot, FetchedAt = document.FetchedAt, OfflineSince = document.OfflineSince }; } if (now <= result.AddHours(24.0)) { return new PhoneRecipeCacheRead { State = PhoneRecipeCacheState.Stale, Snapshot = document.Snapshot, FetchedAt = document.FetchedAt, OfflineSince = document.OfflineSince }; } return new PhoneRecipeCacheRead { State = PhoneRecipeCacheState.Expired, FetchedAt = document.FetchedAt }; } catch { return Empty(); } } public bool Clear() { try { if (File.Exists(_path)) { _deleteFile(_path); } return !File.Exists(_path); } catch { return false; } } public bool ClearForScope(string saveFingerprint) { try { if (!SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint)) { return false; } if (!File.Exists(_path)) { return true; } if (!TryReadDocument(out PhoneRecipeCacheDocument document) || !string.Equals(document.SaveFingerprint, saveFingerprint, StringComparison.Ordinal)) { return true; } return Clear(); } catch { return false; } } internal bool TryCapture(out PhoneRecipeCacheCheckpoint checkpoint) { checkpoint = new PhoneRecipeCacheCheckpoint(Exists: false, null); try { if (!File.Exists(_path)) { return true; } if (!IsBoundedFile()) { return false; } checkpoint = new PhoneRecipeCacheCheckpoint(Exists: true, File.ReadAllBytes(_path)); return true; } catch { return false; } } internal bool Restore(PhoneRecipeCacheCheckpoint checkpoint) { if (!checkpoint.Exists || checkpoint.Bytes == null) { return Clear(); } return WriteRawAtomic(checkpoint.Bytes); } private bool WriteRawAtomic(byte[] bytes) { if (bytes.Length == 0 || bytes.Length > 524288) { return false; } if (ExistingBytesEqual(bytes)) { return true; } string text = null; try { string directoryName = Path.GetDirectoryName(_path); if (!string.IsNullOrWhiteSpace(directoryName)) { Directory.CreateDirectory(directoryName); } text = $"{_path}.{Guid.NewGuid():N}.tmp"; using (FileStream fileStream = new FileStream(text, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { fileStream.Write(bytes, 0, bytes.Length); fileStream.Flush(flushToDisk: true); } _replaceFile(text, _path); text = null; return true; } catch { return false; } finally { if (text != null) { try { File.Delete(text); } catch { } } } } private bool ExistingBytesEqual(byte[] desired) { try { FileInfo fileInfo = new FileInfo(_path); return fileInfo.Exists && fileInfo.Length == desired.Length && File.ReadAllBytes(_path).SequenceEqual(desired); } catch { return false; } } private bool IsBoundedFile() { if (!File.Exists(_path)) { return false; } long length = new FileInfo(_path).Length; if (length > 0) { return length <= 524288; } return false; } private bool TryReadDocument(out PhoneRecipeCacheDocument document) { document = null; if (!IsBoundedFile()) { return false; } byte[] array = File.ReadAllBytes(_path); using JsonDocument jsonDocument = JsonDocument.Parse(array); if (!HasExactProperties(jsonDocument.RootElement, "version", "saveFingerprint", "snapshot", "fetchedAt", "offlineSince")) { return false; } PhoneRecipeCacheDocument phoneRecipeCacheDocument = JsonSerializer.Deserialize(array, JsonOptions); if (phoneRecipeCacheDocument == null || phoneRecipeCacheDocument.Version != 2 || !SaveGenerationIdentity.IsV2Fingerprint(phoneRecipeCacheDocument.SaveFingerprint)) { return false; } document = phoneRecipeCacheDocument; return true; } private static bool HasExactProperties(JsonElement element, params string[] expected) { if (element.ValueKind != JsonValueKind.Object) { return false; } HashSet hashSet = new HashSet(expected, StringComparer.Ordinal); foreach (JsonProperty item in element.EnumerateObject()) { if (!hashSet.Remove(item.Name)) { return false; } } return hashSet.Count == 0; } private static PhoneRecipeCacheRead Empty() { return new PhoneRecipeCacheRead { State = PhoneRecipeCacheState.Empty }; } private static bool IsSafeSnapshot(BridgePhoneRecipeSnapshot snapshot) { if (snapshot == null || snapshot.SchemaVersion != 1 || !IsBoundedString(snapshot.SnapshotId) || snapshot.OmittedCount < 0 || snapshot.Recipes == null || snapshot.Recipes.Count > 50 || !DateTimeOffset.TryParse(snapshot.PublishedAt, out var result) || !DateTimeOffset.TryParse(snapshot.ExpiresAt, out result)) { return false; } foreach (BridgePhoneRecipe recipe in snapshot.Recipes) { if (recipe == null || !IsBoundedString(recipe.RecipeId) || recipe.Name == null || recipe.Name.Length > 64 || !IsBoundedString(recipe.ProductId) || recipe.IngredientIds == null || recipe.IngredientIds.Count == 0 || recipe.IngredientIds.Count > 64 || recipe.IngredientIds.Exists((string id) => !IsBoundedString(id)) || !DateTimeOffset.TryParse(recipe.PublishedAt, out result) || !DateTimeOffset.TryParse(recipe.ExpiresAt, out result) || !double.IsFinite(recipe.SellPrice) || recipe.SellPrice < 0.0 || !double.IsFinite(recipe.Profit) || !double.IsFinite(recipe.ProfitMargin) || recipe.ProfitMargin < -100000.0 || recipe.ProfitMargin > 100000.0 || (recipe.ProfileMode != "standard" && recipe.ProfileMode != "seeded") || (recipe.Source != "app-saved" && recipe.Source != "game-observed")) { return false; } if (recipe.EffectsAvailable) { if (recipe.Effects == null || recipe.Effects.Count > 8 || recipe.Effects.Exists((string effect) => !IsBoundedString(effect))) { return false; } } else if (recipe.Effects != null && recipe.Effects.Count > 0) { return false; } } return true; } private static bool IsBoundedString(string? value) { if (!string.IsNullOrWhiteSpace(value)) { return value.Length <= 64; } return false; } } internal sealed record PhoneRecipeCacheCheckpoint(bool Exists, byte[]? Bytes); internal sealed class PhoneRecipePoller { private readonly BridgeClient _bridgeClient; private readonly PhoneRecipeCache _cache; private readonly Func _utcNow; private readonly TimeSpan _pollInterval; private DateTimeOffset _nextPoll = DateTimeOffset.MinValue; public PhoneRecipePoller(BridgeClient bridgeClient, PhoneRecipeCache cache, Func utcNow, TimeSpan pollInterval) { _bridgeClient = bridgeClient; _cache = cache; _utcNow = utcNow; _pollInterval = pollInterval; } public async Task PollIfDueAsync(string? bridgeToken, string saveFingerprint, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(bridgeToken) || !SaveGenerationIdentity.IsV2Fingerprint(saveFingerprint)) { return; } DateTimeOffset now = _utcNow(); if (now < _nextPoll) { return; } _nextPoll = now + _pollInterval; PhoneRecipePollResult phoneRecipePollResult = await _bridgeClient.GetPhoneRecipeAvailabilityAsync(bridgeToken, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (phoneRecipePollResult == null) { _cache.MarkOffline(now, saveFingerprint); if (_cache.Read(now, saveFingerprint).State == PhoneRecipeCacheState.Expired) { _cache.ClearForScope(saveFingerprint); } } else if (phoneRecipePollResult.NotPublished) { _cache.ClearForScope(saveFingerprint); } else if (phoneRecipePollResult.Snapshot != null) { _cache.Write(phoneRecipePollResult.Snapshot, now, saveFingerprint); } } public void Reset() { _nextPoll = DateTimeOffset.MinValue; } public void ResetAndClearCache() { _cache.Clear(); Reset(); } } internal static class RankLabelNormalizer { private static readonly IReadOnlyDictionary Labels = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["0"] = "Street Rat", ["StreetRat"] = "Street Rat", ["Street_Rat"] = "Street Rat", ["Street Rat"] = "Street Rat", ["1"] = "Hoodlum", ["Hoodlum"] = "Hoodlum", ["2"] = "Peddler", ["Peddler"] = "Peddler", ["3"] = "Hustler", ["Hustler"] = "Hustler", ["4"] = "Bagman", ["Bagman"] = "Bagman", ["5"] = "Enforcer", ["Enforcer"] = "Enforcer", ["6"] = "Shot Caller", ["ShotCaller"] = "Shot Caller", ["Shot_Caller"] = "Shot Caller", ["Shot Caller"] = "Shot Caller", ["7"] = "Block Boss", ["BlockBoss"] = "Block Boss", ["Block_Boss"] = "Block Boss", ["Block Boss"] = "Block Boss", ["8"] = "Underlord", ["Underlord"] = "Underlord", ["9"] = "Baron", ["Baron"] = "Baron", ["10"] = "Kingpin", ["Kingpin"] = "Kingpin" }; public static string? Normalize(string? rawRank, int? tier) { if (string.IsNullOrWhiteSpace(rawRank) || !tier.HasValue || tier.Value < 1) { return null; } string text = rawRank.Trim(); foreach (string value2 in Labels.Values) { string text2 = Format(value2, tier.Value); if (string.Equals(text, text2, StringComparison.Ordinal)) { return text2; } } if (!Labels.TryGetValue(text, out string value)) { return null; } return Format(value, tier.Value); } private static string? Format(string label, int tier) { if (tier <= 5) { return label + " " + RomanNumeral(tier); } if (!string.Equals(label, "Kingpin", StringComparison.Ordinal)) { return null; } return "Kingpin " + tier.ToString(CultureInfo.InvariantCulture); } private static string RomanNumeral(int tier) { return tier switch { 1 => "I", 2 => "II", 3 => "III", 4 => "IV", 5 => "V", _ => throw new ArgumentOutOfRangeException("tier"), }; } } internal sealed record RuntimeActiveSave(string SavePath, string Source); internal static class RuntimeActiveSaveReader { private const string LoadManagerTypeName = "Il2CppScheduleOne.Persistence.LoadManager"; private static readonly BindingFlags PublicInstanceOrStatic = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public; private static readonly Regex SaveSlotPattern = new Regex("(? string.Equals(assembly.GetName().Name, "Assembly-CSharp", StringComparison.Ordinal))?.GetType("Il2CppScheduleOne.Persistence.LoadManager", throwOnError: false); object obj = FindProperty(type, "Instance")?.GetValue(null) ?? FindProperty(type, "instance")?.GetValue(null); if (obj == null) { return null; } object obj2 = FindProperty(type, "ActiveSaveInfo")?.GetValue(obj); string text = FindProperty(obj2?.GetType(), "SavePath")?.GetValue(obj2) as string; if (ExtractSaveSlot(text) != null) { return new RuntimeActiveSave(text, "LoadManager.ActiveSaveInfo.SavePath"); } string text2 = FindProperty(type, "LoadedGameFolderPath")?.GetValue(obj) as string; if (ExtractSaveSlot(text2) != null) { return new RuntimeActiveSave(text2, "LoadManager.LoadedGameFolderPath"); } } catch { } return null; } internal static string? ExtractSaveSlot(string? path) { if (string.IsNullOrWhiteSpace(path)) { return null; } Match match = SaveSlotPattern.Match(path); if (!match.Success) { return null; } return match.Value; } private static PropertyInfo? FindProperty(Type? type, string name) { Type type2 = type; while (type2 != null) { PropertyInfo property = type2.GetProperty(name, PublicInstanceOrStatic | BindingFlags.DeclaredOnly); if (property != null) { return property; } type2 = type2.BaseType; } return null; } } internal sealed record ProvenSaveIdentity(string SteamIdHash, string SlotId, string Fingerprint); internal static class SaveGenerationIdentity { internal const string FingerprintV2Prefix = "v2-"; private const long MaxMetadataBytes = 1048576L; internal static bool TryCreate(string activeSaveDirectory, out ProvenSaveIdentity? identity, out string reason) { identity = null; reason = "generation-metadata-unavailable"; try { if (!TryGetDirectoryIdentity(activeSaveDirectory, out string steamDirectory, out string slotId)) { reason = "generation-save-path-invalid"; return false; } if (!TryReadMetadata(activeSaveDirectory, out var creation) || !TryReadSeed(activeSaveDirectory, out var seed)) { return false; } string text = Sha256(Path.GetFileName(steamDirectory)); string value = string.Join("\n", "hyland-helper/save-fingerprint/v2", "steam=" + text, "slot=" + slotId.ToLowerInvariant(), "created=" + creation.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture), "seed=" + seed.ToString(CultureInfo.InvariantCulture)); identity = new ProvenSaveIdentity(text, slotId, "v2-" + Sha256(value)); reason = string.Empty; return true; } catch (IOException) { return false; } catch (JsonException) { return false; } catch (UnauthorizedAccessException) { return false; } } internal static bool IsV2Fingerprint(string? value) { if (value == null || value.Length != "v2-".Length + 64 || !value.StartsWith("v2-", StringComparison.Ordinal)) { return false; } for (int i = "v2-".Length; i < value.Length; i++) { char c = value[i]; if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) { return false; } } return true; } private static bool TryGetDirectoryIdentity(string activeSaveDirectory, out string steamDirectory, out string slotId) { steamDirectory = string.Empty; slotId = string.Empty; if (string.IsNullOrWhiteSpace(activeSaveDirectory)) { return false; } string path = Path.GetFullPath(activeSaveDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (!Directory.Exists(path)) { return false; } slotId = Path.GetFileName(path); if (!IsSaveSlot(slotId)) { return false; } steamDirectory = Path.GetDirectoryName(path) ?? string.Empty; return !string.IsNullOrWhiteSpace(Path.GetFileName(steamDirectory)); } private static bool TryReadMetadata(string saveDirectory, out DateTime creation) { creation = default(DateTime); if (!TryReadJson(Path.Combine(saveDirectory, "Metadata.json"), out JsonDocument document)) { return false; } using (document) { JsonElement rootElement = document.RootElement; if (!HasString(rootElement, "DataType", "MetaData") || !HasInt32(rootElement, "DataVersion") || !HasNonEmptyString(rootElement, "GameVersion") || !HasNonEmptyString(rootElement, "CreationVersion") || !TryGetUniqueProperty(rootElement, "CreationDate", out var value) || value.ValueKind != JsonValueKind.Object || !HasNonEmptyString(value, "DataType") || !HasInt32(value, "DataVersion") || !HasNonEmptyString(value, "GameVersion") || !TryGetInt32(value, "Year", out var value2) || !TryGetInt32(value, "Month", out var value3) || !TryGetInt32(value, "Day", out var value4) || !TryGetInt32(value, "Hour", out var value5) || !TryGetInt32(value, "Minute", out var value6) || !TryGetInt32(value, "Second", out var value7)) { return false; } try { creation = new DateTime(value2, value3, value4, value5, value6, value7, DateTimeKind.Unspecified); return true; } catch (ArgumentOutOfRangeException) { return false; } } } private static bool TryReadSeed(string saveDirectory, out long seed) { seed = 0L; if (!TryReadJson(Path.Combine(saveDirectory, "Game.json"), out JsonDocument document)) { return false; } using (document) { JsonElement rootElement = document.RootElement; return HasString(rootElement, "DataType", "GameData") && HasInt32(rootElement, "DataVersion") && HasNonEmptyString(rootElement, "GameVersion") && TryGetInt64(rootElement, "Seed", out seed); } } private static bool TryReadJson(string path, out JsonDocument document) { document = null; FileInfo fileInfo = new FileInfo(path); if (!fileInfo.Exists || fileInfo.Length > 1048576) { return false; } using FileStream utf8Json = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); document = JsonDocument.Parse(utf8Json); return true; } private static bool HasString(JsonElement element, string propertyName, string expected) { if (TryGetUniqueProperty(element, propertyName, out var value) && value.ValueKind == JsonValueKind.String) { return string.Equals(value.GetString(), expected, StringComparison.Ordinal); } return false; } private static bool HasNonEmptyString(JsonElement element, string propertyName) { if (TryGetUniqueProperty(element, propertyName, out var value) && value.ValueKind == JsonValueKind.String) { return !string.IsNullOrWhiteSpace(value.GetString()); } return false; } private static bool HasInt32(JsonElement element, string propertyName) { int value; return TryGetInt32(element, propertyName, out value); } private static bool TryGetInt32(JsonElement element, string propertyName, out int value) { value = 0; if (TryGetUniqueProperty(element, propertyName, out var value2) && value2.ValueKind == JsonValueKind.Number) { return value2.TryGetInt32(out value); } return false; } private static bool TryGetInt64(JsonElement element, string propertyName, out long value) { value = 0L; if (TryGetUniqueProperty(element, propertyName, out var value2) && value2.ValueKind == JsonValueKind.Number) { return value2.TryGetInt64(out value); } return false; } private static bool TryGetUniqueProperty(JsonElement element, string propertyName, out JsonElement value) { value = default(JsonElement); if (element.ValueKind != JsonValueKind.Object) { return false; } bool flag = false; foreach (JsonProperty item in element.EnumerateObject()) { if (string.Equals(item.Name, propertyName, StringComparison.Ordinal)) { if (flag) { value = default(JsonElement); return false; } flag = true; value = item.Value; } } return flag; } private static bool IsSaveSlot(string value) { if (!value.StartsWith("SaveGame_", StringComparison.OrdinalIgnoreCase) || value.Length == "SaveGame_".Length) { return false; } for (int i = "SaveGame_".Length; i < value.Length; i++) { if (value[i] < '0' || value[i] > '9') { return false; } } return true; } private static string Sha256(string value) { using SHA256 sHA = SHA256.Create(); return Convert.ToHexString(sHA.ComputeHash(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); } } internal sealed class SaveSnapshotParser { private sealed record CashAggregation(decimal Total, bool IncludesPlayerInventory, bool IncludesStorage, List MissingContainers, bool IsReliable); private sealed class PropertyScanResult { public List Summaries { get; } = new List(); public List LayoutProperties { get; } = new List(); public List StorageSources { get; } = new List(); public bool SourceDirectoryReadable { get; set; } public bool LayoutPartial { get; set; } public bool LayoutTruncated { get; set; } } private sealed record StorageSource(string Source, string Container, List Items); private const int MaxInventoryItems = 150; private const int MaxInventorySources = 60; private const int MaxDiscoveredProducts = 75; private const int MaxListedProducts = 100; private const int MaxRecipes = 40; private const int MaxLayoutObjectsPerProperty = 5000; private const int MaxLayoutProperties = 100; private const int MaxLayoutPayloadBytes = 819200; private const int MaxContractStringLength = 128; private readonly MelonBridgeLogger _logger; private readonly Func> _featureProvider; public SaveSnapshotParser(MelonBridgeLogger logger, Func>? featureProvider = null) { _logger = logger; _featureProvider = featureProvider ?? ((Func>)(() => BridgeFeatureAdvertisement.Current())); } public ParsedSnapshot Parse(DetectedSave save, string capturedAt) { List warnings = new List(); BridgeCapabilities capabilities = BaseCapabilities(save); BridgeSnapshot bridgeSnapshot = new BridgeSnapshot { CapturedAt = capturedAt, Status = "degraded", Warnings = warnings }; if (save.IsFallback || string.IsNullOrWhiteSpace(save.SlotDirectory) || !Directory.Exists(save.SlotDirectory)) { AddWarning(warnings, save.StatusLine); return new ParsedSnapshot(bridgeSnapshot, capabilities); } PropertyScanResult scan = ScanPropertyFiles(save, warnings); Exception failure; BridgeInventorySnapshot inventory = TryBuildInventorySnapshot(save, scan, warnings, out failure); TryParseMoney(save, bridgeSnapshot, capabilities, warnings, inventory); TryParseRank(save, bridgeSnapshot, capabilities, warnings); TryParseTime(save, bridgeSnapshot, capabilities, warnings); TryParseInventory(bridgeSnapshot, capabilities, warnings, inventory, failure); TryParseProperties(scan, bridgeSnapshot, capabilities, warnings); TryParseProducts(save, bridgeSnapshot, capabilities, warnings); TryParseLayout(scan, bridgeSnapshot, capabilities); bridgeSnapshot.Slices = new List(); if (bridgeSnapshot.Money != null) { bridgeSnapshot.Slices.Add("money"); } if (bridgeSnapshot.Rank != null) { bridgeSnapshot.Slices.Add("rank"); } if (bridgeSnapshot.Time != null) { bridgeSnapshot.Slices.Add("time"); } if (bridgeSnapshot.Inventory != null) { bridgeSnapshot.Slices.Add("inventory"); } if (bridgeSnapshot.Properties != null) { bridgeSnapshot.Slices.Add("properties"); } if (bridgeSnapshot.Products != null) { bridgeSnapshot.Slices.Add("products"); } if (bridgeSnapshot.Layout != null) { bridgeSnapshot.Slices.Add("layout"); } BridgeCapabilitySlice value; int num = new string[7] { "money", "rank", "time", "inventory", "properties", "products", "layout" }.Count((string slice) => capabilities.Slices.TryGetValue(slice, out value) && value.Status == "unavailable"); bridgeSnapshot.Status = ((bridgeSnapshot.Slices.Count == 0) ? "degraded" : ((num == 0) ? "complete" : "partial")); return new ParsedSnapshot(bridgeSnapshot, capabilities); } private BridgeCapabilities BaseCapabilities(DetectedSave save) { return new BridgeCapabilities { Slices = new Dictionary { ["presence"] = new BridgeCapabilitySlice { Status = (save.IsFallback ? "degraded" : "present"), Source = "diskSave", Reason = (save.IsFallback ? save.StatusLine : null) }, ["profile"] = new BridgeCapabilitySlice { Status = (save.IsFallback ? "degraded" : "partial"), Source = "diskSave", Reason = "Save slot and fingerprint detected." }, ["money"] = unavailable("Money.json was not parsed yet."), ["rank"] = unavailable("Rank.json was not parsed yet."), ["time"] = unavailable("Time.json was not parsed yet."), ["inventory"] = unavailable("Inventory/backpack/storage data was not parsed yet."), ["properties"] = unavailable("Property/business data was not parsed yet."), ["products"] = unavailable("Products.json was not parsed yet."), ["layout"] = unavailable("Property/business save data was not parsed yet."), ["session"] = unavailable("Session timeline is not reported by the bridge.") }, Features = _featureProvider() }; static BridgeCapabilitySlice unavailable(string reason) { return new BridgeCapabilitySlice { Status = "unavailable", Source = "notSupported", Reason = reason }; } } private void TryParseMoney(DetectedSave save, BridgeSnapshot snapshot, BridgeCapabilities capabilities, List warnings, BridgeInventorySnapshot? inventory) { try { using JsonDocument jsonDocument = ReadTypedDocument(Path.Combine(save.SlotDirectory, "Money.json"), "MoneyData", save.GameVersion); JsonElement rootElement = jsonDocument.RootElement; CashAggregation cashAggregation = AggregateCashOnHand(inventory); snapshot.Money = new BridgeMoneySnapshot { OnlineBalance = GetDecimal(rootElement, "OnlineBalance").GetValueOrDefault(), Networth = GetDecimal(rootElement, "Networth").GetValueOrDefault(), LifetimeEarnings = GetDecimal(rootElement, "LifetimeEarnings").GetValueOrDefault(), CashOnHandTotal = cashAggregation.Total, AppProfileCash = cashAggregation.Total, AppProfileCashSource = "cashOnHandTotal", CashAggregation = new BridgeCashAggregation { IncludesPlayerInventory = cashAggregation.IncludesPlayerInventory, IncludesStorage = cashAggregation.IncludesStorage, MissingContainers = cashAggregation.MissingContainers } }; SetCapability(capabilities, "money", cashAggregation.IsReliable ? "present" : "partial", "diskSave", cashAggregation.IsReliable ? null : "Cash aggregation did not include player inventory; app profile cash is unreliable.", rootElement); } catch (Exception exception) { MarkUnavailable(capabilities, "money", "Money.json unavailable or malformed."); AddWarning(warnings, "Money slice unavailable."); _logger.WarningThrottled("Money parse quiet-failed.", exception); } } private void TryParseRank(DetectedSave save, BridgeSnapshot snapshot, BridgeCapabilities capabilities, List warnings) { try { using JsonDocument jsonDocument = ReadTypedDocument(Path.Combine(save.SlotDirectory, "Rank.json"), "RankData", save.GameVersion); JsonElement rootElement = jsonDocument.RootElement; int? tier = GetInt(rootElement, "Tier"); snapshot.Rank = new BridgeRankSnapshot { Rank = RankLabelNormalizer.Normalize(GetRawString(rootElement, "Rank"), tier), Tier = tier, Xp = GetDecimal(rootElement, "XP"), TotalXp = GetDecimal(rootElement, "TotalXP"), UnlockedRegions = GetArrayStrings(rootElement, "UnlockedRegions", 100) }; SetCapability(capabilities, "rank", "present", "diskSave", null, rootElement); } catch (Exception exception) { MarkUnavailable(capabilities, "rank", "Rank.json unavailable or malformed."); AddWarning(warnings, "Rank slice unavailable."); _logger.WarningThrottled("Rank parse quiet-failed.", exception); } } private void TryParseTime(DetectedSave save, BridgeSnapshot snapshot, BridgeCapabilities capabilities, List warnings) { try { using JsonDocument jsonDocument = ReadTypedDocument(Path.Combine(save.SlotDirectory, "Time.json"), "TimeData", save.GameVersion); JsonElement rootElement = jsonDocument.RootElement; snapshot.Time = new BridgeTimeSnapshot { TimeOfDay = GetDecimal(rootElement, "TimeOfDay"), ElapsedDays = GetInt(rootElement, "ElapsedDays"), PlaytimeSeconds = GetDecimal(rootElement, "Playtime") }; SetCapability(capabilities, "time", "present", "diskSave", null, rootElement); } catch (Exception exception) { MarkUnavailable(capabilities, "time", "Time.json unavailable or malformed."); AddWarning(warnings, "Time slice unavailable."); _logger.WarningThrottled("Time parse quiet-failed.", exception); } } private void TryParseInventory(BridgeSnapshot snapshot, BridgeCapabilities capabilities, List warnings, BridgeInventorySnapshot? inventory, Exception? inventoryFailure) { try { if (inventoryFailure != null) { throw inventoryFailure; } if (inventory == null) { throw new InvalidDataException("Inventory snapshot unavailable."); } snapshot.Inventory = inventory; SetCapability(capabilities, "inventory", inventory.Truncated ? "partial" : "present", "diskSave", inventory.Truncated ? "Inventory snapshot was capped for bridge payload size." : null, null); } catch (Exception exception) { MarkUnavailable(capabilities, "inventory", "Inventory files unavailable or malformed."); AddWarning(warnings, "Inventory slice unavailable."); _logger.WarningThrottled("Inventory parse quiet-failed.", exception); } } private BridgeInventorySnapshot? TryBuildInventorySnapshot(DetectedSave save, PropertyScanResult scan, List warnings, out Exception? failure) { try { failure = null; return BuildInventorySnapshot(save, scan, warnings); } catch (Exception ex) { failure = ex; return null; } } private void TryParseProperties(PropertyScanResult scan, BridgeSnapshot snapshot, BridgeCapabilities capabilities, List warnings) { try { snapshot.Properties = new BridgePropertiesSnapshot { OwnedCount = scan.Summaries.Count((BridgePropertySummary item) => item.IsOwned), TotalCount = scan.Summaries.Count, Properties = scan.Summaries.Take(100).ToList() }; SetCapability(capabilities, "properties", "present", "diskSave", null, null); } catch (Exception exception) { MarkUnavailable(capabilities, "properties", "Property/business files unavailable or malformed."); AddWarning(warnings, "Properties slice unavailable."); _logger.WarningThrottled("Properties parse quiet-failed.", exception); } } private static void TryParseLayout(PropertyScanResult scan, BridgeSnapshot snapshot, BridgeCapabilities capabilities) { if (!scan.SourceDirectoryReadable) { MarkUnavailable(capabilities, "layout", "Property/business save directories were unavailable."); return; } snapshot.Layout = new BridgeLayoutSnapshot { Properties = scan.LayoutProperties, Truncated = scan.LayoutTruncated }; SetCapability(capabilities, "layout", scan.LayoutPartial ? "partial" : "present", "diskSave", scan.LayoutPartial ? "Layout extraction was incomplete; see snapshot warnings." : null, null); } private void TryParseProducts(DetectedSave save, BridgeSnapshot snapshot, BridgeCapabilities capabilities, List warnings) { try { using JsonDocument jsonDocument = ReadTypedDocument(Path.Combine(save.SlotDirectory, "Products.json"), "ProductManagerData", save.GameVersion); JsonElement rootElement = jsonDocument.RootElement; List arrayStrings = GetArrayStrings(rootElement, "DiscoveredProducts", 75); List arrayStrings2 = GetArrayStrings(rootElement, "ListedProducts", 100); JsonElement value; int num = ((rootElement.TryGetProperty("MixRecipes", out value) && value.ValueKind == JsonValueKind.Array) ? value.GetArrayLength() : 0); List list = new List(); if (value.ValueKind == JsonValueKind.Array) { foreach (JsonElement item in value.EnumerateArray().Take(40)) { list.Add(new BridgeMixRecipeSummary { Product = (GetString(item, "Product") ?? ""), Mixer = (GetString(item, "Mixer") ?? ""), Output = (GetString(item, "Output") ?? "") }); } } snapshot.Products = new BridgeProductsSnapshot { DiscoveredCount = ((rootElement.TryGetProperty("DiscoveredProducts", out var value2) && value2.ValueKind == JsonValueKind.Array) ? value2.GetArrayLength() : arrayStrings.Count), DiscoveredProducts = arrayStrings, ListedProducts = arrayStrings2, ActiveMixOperation = ParseActiveMix(rootElement), IsMixComplete = GetBool(rootElement, "IsMixComplete"), RecipeCount = num, Recipes = list, Truncated = (arrayStrings.Count >= 75 || num > list.Count) }; SetCapability(capabilities, "products", snapshot.Products.Truncated ? "partial" : "present", "diskSave", snapshot.Products.Truncated ? "Products snapshot was capped for bridge payload size." : null, rootElement); } catch (Exception exception) { MarkUnavailable(capabilities, "products", "Products.json unavailable or malformed."); AddWarning(warnings, "Products slice unavailable."); _logger.WarningThrottled("Products parse quiet-failed.", exception); } } private BridgeInventorySnapshot BuildInventorySnapshot(DetectedSave save, PropertyScanResult scan, List warnings) { List list = new List(); List list2 = new List(); ParseInventoryFile(Path.Combine(save.SlotDirectory, "Players", "Player_0", "Inventory.json"), "playerInventory", "Player_0 inventory", save.GameVersion, list, list2, warnings); ParseBackpackFile(Path.Combine(save.SlotDirectory, "Players", "Player_0", "Backpack.json"), save.GameVersion, list, list2, warnings); foreach (StorageSource storageSource in scan.StorageSources) { AddInventorySource(storageSource.Source, storageSource.Container, storageSource.Items, list, list2); } decimal cashOnHandTotal = list.Where((BridgeInventoryItemStack item) => string.Equals(item.DataType, "CashData", StringComparison.OrdinalIgnoreCase)).Sum((BridgeInventoryItemStack item) => item.Quantity); List list3 = list.Where((BridgeInventoryItemStack item) => !string.Equals(item.DataType, "CashData", StringComparison.OrdinalIgnoreCase)).Take(150).ToList(); decimal totalQuantity = list.Where((BridgeInventoryItemStack item) => !string.Equals(item.DataType, "CashData", StringComparison.OrdinalIgnoreCase)).Sum((BridgeInventoryItemStack item) => item.Quantity); return new BridgeInventorySnapshot { TotalStacks = list.Count((BridgeInventoryItemStack item) => !string.Equals(item.DataType, "CashData", StringComparison.OrdinalIgnoreCase)), TotalQuantity = totalQuantity, CashOnHandTotal = cashOnHandTotal, Sources = list2.Take(60).ToList(), Items = list3, Truncated = (list3.Count < list.Count((BridgeInventoryItemStack item) => !string.Equals(item.DataType, "CashData", StringComparison.OrdinalIgnoreCase)) || list2.Count > 60) }; } private static CashAggregation AggregateCashOnHand(BridgeInventorySnapshot? inventory) { if (inventory == null) { return new CashAggregation(0m, IncludesPlayerInventory: false, IncludesStorage: false, new List { "cash aggregation unavailable" }, IsReliable: false); } bool flag = inventory.Sources.Any((BridgeInventorySourceSummary source) => source.Source == "playerInventory"); List list = new List(); if (!flag) { list.Add("player inventory"); } return new CashAggregation(inventory.CashOnHandTotal, flag || inventory.Sources.Any((BridgeInventorySourceSummary source) => source.Source == "backpack"), inventory.Sources.Any((BridgeInventorySourceSummary source) => source.Source == "storage"), list, flag); } private void ParseInventoryFile(string path, string source, string container, string gameVersion, List items, List sources, List warnings) { try { using JsonDocument jsonDocument = ReadDocument(path); List parsed = ParseItemsArray(jsonDocument.RootElement, source, container, gameVersion); AddInventorySource(source, container, parsed, items, sources); } catch (Exception exception) { AddWarning(warnings, container + " unavailable."); _logger.WarningThrottled(container + " parse quiet-failed.", exception); } } private void ParseBackpackFile(string path, string gameVersion, List items, List sources, List warnings) { try { using JsonDocument jsonDocument = ReadDocument(path); if (!jsonDocument.RootElement.TryGetProperty("Contents", out var value) || value.ValueKind != JsonValueKind.String) { AddWarning(warnings, "Backpack contents unavailable."); return; } using JsonDocument jsonDocument2 = JsonDocument.Parse(value.GetString() ?? "{}"); List parsed = ParseItemsArray(jsonDocument2.RootElement, "backpack", "Backpack", gameVersion); AddInventorySource("backpack", "Backpack", parsed, items, sources); } catch (Exception exception) { AddWarning(warnings, "Backpack unavailable."); _logger.WarningThrottled("Backpack parse quiet-failed.", exception); } } private PropertyScanResult ScanPropertyFiles(DetectedSave save, List warnings) { PropertyScanResult propertyScanResult = new PropertyScanResult(); int layoutPayloadBytes = JsonSerializer.SerializeToUtf8Bytes(new BridgeLayoutSnapshot()).Length; bool flag = false; (string, string)[] array = new(string, string)[2] { ("Properties", "property"), ("Businesses", "business") }; for (int i = 0; i < array.Length; i++) { (string, string) tuple = array[i]; string item = tuple.Item1; string item2 = tuple.Item2; string path = Path.Combine(save.SlotDirectory, item); if (!Directory.Exists(path)) { continue; } try { List list = Directory.EnumerateFiles(path, "*.json").OrderBy((string file) => file, StringComparer.Ordinal).ToList(); propertyScanResult.SourceDirectoryReadable = true; foreach (string item3 in list) { bool flag2 = false; try { using JsonDocument jsonDocument = ReadDocument(item3); JsonElement rootElement = jsonDocument.RootElement; string a = GetString(rootElement, "DataType"); bool num; if (!(item2 == "business")) { if (string.Equals(a, "PropertyData", StringComparison.Ordinal)) { goto IL_013b; } num = string.Equals(a, "ManorData", StringComparison.Ordinal); } else { num = string.Equals(a, "BusinessData", StringComparison.Ordinal); } if (num) { goto IL_013b; } goto IL_014a; IL_013b: if (!IsVersionCompatible(rootElement, save.GameVersion)) { goto IL_014a; } flag2 = GetBool(rootElement, "IsOwned") == true; string text = GetString(rootElement, "PropertyCode"); if (string.IsNullOrWhiteSpace(text)) { text = Path.GetFileNameWithoutExtension(item3); } if (text.Length == 0 || text.Length > 128) { throw new InvalidDataException(Path.GetFileName(item3) + " had an unusable PropertyCode."); } propertyScanResult.Summaries.Add(new BridgePropertySummary { Code = text, Kind = item2, IsOwned = flag2, ObjectCount = GetArrayLength(rootElement, "Objects"), EmployeeCount = GetArrayLength(rootElement, "Employees"), LaunderingOperationCount = GetArrayLength(rootElement, "LaunderingOperations") }); if (!rootElement.TryGetProperty("Objects", out var value) || value.ValueKind != JsonValueKind.Array) { throw new InvalidDataException(Path.GetFileName(item3) + " had invalid Objects."); } if (flag2) { AddLayoutProperty(propertyScanResult, text, value, ref layoutPayloadBytes); } propertyScanResult.StorageSources.AddRange(ParseStorageSources(value, item3, save.GameVersion)); goto end_IL_00e6; IL_014a: throw new InvalidDataException(Path.GetFileName(item3) + " had unexpected DataType or GameVersion."); end_IL_00e6:; } catch (Exception exception) { AddWarning(warnings, Path.GetFileNameWithoutExtension(item3) + " " + item2 + " unavailable."); if (flag2) { propertyScanResult.LayoutPartial = true; } _logger.WarningThrottled(Path.GetFileNameWithoutExtension(item3) + " " + item2 + " parse quiet-failed.", exception); } } } catch (Exception exception2) { flag = true; AddWarning(warnings, item + " directory unavailable."); _logger.WarningThrottled(item + " directory scan quiet-failed.", exception2); } } if (propertyScanResult.SourceDirectoryReadable && flag) { propertyScanResult.LayoutPartial = true; } return propertyScanResult; } private static List ParseStorageSources(JsonElement objects, string file, string gameVersion) { List list = new List(); foreach (JsonElement item in objects.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object || !item.TryGetProperty("BaseData", out var value) || value.ValueKind != JsonValueKind.String) { continue; } try { using JsonDocument jsonDocument = JsonDocument.Parse(value.GetString() ?? "{}"); JsonElement rootElement = jsonDocument.RootElement; if (rootElement.TryGetProperty("Contents", out var value2)) { string text = TryReadItemId(rootElement); string container = Path.GetFileNameWithoutExtension(file) + ":" + (text ?? GetString(rootElement, "GUID") ?? "storage"); List list2 = ParseItemsArray(value2, "storage", container, gameVersion); if (list2.Count > 0) { list.Add(new StorageSource("storage", container, list2)); } } } catch (Exception) { } } return list; } private static void AddLayoutProperty(PropertyScanResult scan, string propertyCode, JsonElement objects, ref int layoutPayloadBytes) { if (scan.LayoutProperties.Count >= 100) { scan.LayoutTruncated = true; scan.LayoutPartial = true; return; } BridgeLayoutProperty bridgeLayoutProperty = new BridgeLayoutProperty { PropertyCode = propertyCode }; scan.LayoutProperties.Add(bridgeLayoutProperty); layoutPayloadBytes = JsonSerializer.SerializeToUtf8Bytes(new BridgeLayoutSnapshot { Properties = scan.LayoutProperties }).Length; if (layoutPayloadBytes > 819200) { scan.LayoutProperties.RemoveAt(scan.LayoutProperties.Count - 1); scan.LayoutTruncated = true; scan.LayoutPartial = true; layoutPayloadBytes = JsonSerializer.SerializeToUtf8Bytes(new BridgeLayoutSnapshot { Properties = scan.LayoutProperties }).Length; return; } foreach (JsonElement item in objects.EnumerateArray()) { if (bridgeLayoutProperty.Objects.Count >= 5000) { bridgeLayoutProperty.Truncated = true; scan.LayoutPartial = true; break; } BridgeLayoutObject bridgeLayoutObject = ParseLayoutObject(item); int num = JsonSerializer.SerializeToUtf8Bytes(bridgeLayoutObject).Length + ((bridgeLayoutProperty.Objects.Count > 0) ? 1 : 0); if (layoutPayloadBytes + num > 819200) { bridgeLayoutProperty.Truncated = true; scan.LayoutPartial = true; break; } bridgeLayoutProperty.Objects.Add(bridgeLayoutObject); layoutPayloadBytes += num; } } private static BridgeLayoutObject ParseLayoutObject(JsonElement item) { string text = ((item.ValueKind == JsonValueKind.Object) ? (GetString(item, "DataType") ?? "") : ""); BridgeLayoutObject bridgeLayoutObject = new BridgeLayoutObject { DataType = ((text.Length > 128) ? text.Substring(0, 128) : text) }; if (item.ValueKind != JsonValueKind.Object || !item.TryGetProperty("BaseData", out var value) || value.ValueKind != JsonValueKind.String) { return bridgeLayoutObject; } try { using JsonDocument jsonDocument = JsonDocument.Parse(value.GetString() ?? "{}"); JsonElement rootElement = jsonDocument.RootElement; bridgeLayoutObject.GridGuid = BoundedIdentifier(GetString(rootElement, "GridGUID")); bridgeLayoutObject.Rotation = GetIntegralInt(rootElement, "Rotation"); if (rootElement.TryGetProperty("OriginCoordinate", out var value2) && value2.ValueKind == JsonValueKind.Object) { bridgeLayoutObject.X = GetIntegralInt(value2, "x"); bridgeLayoutObject.Y = GetIntegralInt(value2, "y"); } if (rootElement.TryGetProperty("ItemString", out var value3) && value3.ValueKind == JsonValueKind.String) { try { using JsonDocument jsonDocument2 = JsonDocument.Parse(value3.GetString() ?? "{}"); bridgeLayoutObject.ItemId = BoundedIdentifier(GetString(jsonDocument2.RootElement, "ID")); } catch (JsonException) { } } } catch (Exception) { } if (IsMountedChild(bridgeLayoutObject.DataType)) { bridgeLayoutObject.GridGuid = null; bridgeLayoutObject.X = null; bridgeLayoutObject.Y = null; } return bridgeLayoutObject; } private static bool IsMountedChild(string dataType) { return string.Equals(dataType, "ProceduralGridItemData", StringComparison.Ordinal); } private static string? BoundedIdentifier(string? value) { if (value == null || value.Length > 128) { return null; } return value; } private static List ParseItemsArray(JsonElement root, string source, string container, string gameVersion) { List list = new List(); if (!root.TryGetProperty("Items", out var value) || value.ValueKind != JsonValueKind.Array) { return list; } foreach (JsonElement item in value.EnumerateArray()) { if (item.ValueKind != JsonValueKind.String) { continue; } using JsonDocument jsonDocument = JsonDocument.Parse(item.GetString() ?? "{}"); JsonElement rootElement = jsonDocument.RootElement; if (!IsVersionCompatible(rootElement, gameVersion)) { continue; } string text = GetString(rootElement, "ID"); decimal valueOrDefault = GetDecimal(rootElement, "Quantity").GetValueOrDefault(); if (!string.IsNullOrWhiteSpace(text) && !(valueOrDefault <= 0m)) { string text2 = GetString(rootElement, "DataType"); decimal num = (string.Equals(text2, "CashData", StringComparison.OrdinalIgnoreCase) ? GetDecimal(rootElement, "CashBalance").GetValueOrDefault() : valueOrDefault); if (!(num <= 0m)) { list.Add(new BridgeInventoryItemStack { Id = text, Quantity = num, Source = source, Container = container, DataType = text2, Quality = GetString(rootElement, "Quality"), PackagingId = GetString(rootElement, "PackagingID") }); } } } return list; } private static void AddInventorySource(string source, string container, List parsed, List allItems, List sources) { allItems.AddRange(parsed); sources.Add(new BridgeInventorySourceSummary { Source = source, Container = container, ItemStacks = parsed.Count((BridgeInventoryItemStack item) => !string.Equals(item.DataType, "CashData", StringComparison.OrdinalIgnoreCase)), CashOnHand = parsed.Where((BridgeInventoryItemStack item) => string.Equals(item.DataType, "CashData", StringComparison.OrdinalIgnoreCase)).Sum((BridgeInventoryItemStack item) => item.Quantity) }); } private static BridgeActiveMixOperation? ParseActiveMix(JsonElement root) { if (!root.TryGetProperty("ActiveMixOperation", out var value) || value.ValueKind != JsonValueKind.Object) { return null; } return new BridgeActiveMixOperation { ProductId = GetString(value, "ProductID"), IngredientId = GetString(value, "IngredientID") }; } private static JsonDocument ReadTypedDocument(string path, string dataType, string gameVersion) { JsonDocument jsonDocument = ReadDocument(path); try { JsonElement rootElement = jsonDocument.RootElement; if (!string.Equals(GetString(rootElement, "DataType"), dataType, StringComparison.Ordinal)) { throw new InvalidDataException(Path.GetFileName(path) + " had unexpected DataType."); } if (!IsVersionCompatible(rootElement, gameVersion)) { throw new InvalidDataException(Path.GetFileName(path) + " had unexpected GameVersion."); } return jsonDocument; } catch { jsonDocument.Dispose(); throw; } } private static JsonDocument ReadDocument(string path) { using FileStream utf8Json = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); return JsonDocument.Parse(utf8Json); } private static bool IsVersionCompatible(JsonElement root, string gameVersion) { string text = GetString(root, "GameVersion"); if (!string.IsNullOrWhiteSpace(text)) { return string.Equals(text, gameVersion, StringComparison.Ordinal); } return true; } private static void SetCapability(BridgeCapabilities capabilities, string name, string status, string source, string? reason, JsonElement? root) { capabilities.Slices[name] = new BridgeCapabilitySlice { Status = status, Source = source, Reason = reason, GameDataType = (root.HasValue ? GetString(root.Value, "DataType") : null), GameDataVersion = (root.HasValue ? GetInt(root.Value, "DataVersion") : ((int?)null)) }; } private static void MarkUnavailable(BridgeCapabilities capabilities, string name, string reason) { capabilities.Slices[name] = new BridgeCapabilitySlice { Status = "unavailable", Source = "notSupported", Reason = reason }; } private static void AddWarning(List warnings, string warning) { if (warnings.Count < 50 && !string.IsNullOrWhiteSpace(warning)) { warnings.Add((warning.Length > 256) ? warning.Substring(0, 256) : warning); } } private static string? TryReadItemId(JsonElement root) { try { if (!root.TryGetProperty("ItemString", out var value) || value.ValueKind != JsonValueKind.String) { return null; } using JsonDocument jsonDocument = JsonDocument.Parse(value.GetString() ?? "{}"); return GetString(jsonDocument.RootElement, "ID"); } catch { return null; } } private static string? GetString(JsonElement root, string name) { if (!root.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String) { return null; } return value.GetString(); } private static string? GetRawString(JsonElement root, string name) { if (!root.TryGetProperty(name, out var value)) { return null; } if (value.ValueKind != JsonValueKind.String) { return value.ToString(); } return value.GetString(); } private static decimal? GetDecimal(JsonElement root, string name) { if (!root.TryGetProperty(name, out var value) || !value.TryGetDecimal(out var value2)) { return null; } return value2; } private static int? GetInt(JsonElement root, string name) { if (!root.TryGetProperty(name, out var value) || !value.TryGetInt32(out var value2)) { return null; } return value2; } private static int? GetIntegralInt(JsonElement root, string name) { if (!root.TryGetProperty(name, out var value) || !value.TryGetDecimal(out var value2)) { return null; } if (value2 != decimal.Truncate(value2) || value2 < -2147483648m || value2 > 2147483647m) { return null; } return (int)value2; } private static bool? GetBool(JsonElement root, string name) { if (!root.TryGetProperty(name, out var value) || (value.ValueKind != JsonValueKind.True && value.ValueKind != JsonValueKind.False)) { return null; } return value.GetBoolean(); } private static int GetArrayLength(JsonElement root, string name) { if (!root.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array) { return 0; } return value.GetArrayLength(); } private static List GetArrayStrings(JsonElement root, string name, int limit) { List list = new List(); if (!root.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array) { return list; } foreach (JsonElement item in value.EnumerateArray().Take(limit)) { string text = ((item.ValueKind == JsonValueKind.String) ? item.GetString() : item.ToString()); if (!string.IsNullOrWhiteSpace(text)) { list.Add(text); } } return list; } } internal sealed record ParsedSnapshot(BridgeSnapshot Snapshot, BridgeCapabilities Capabilities); internal sealed record ScreenActivityObservation(bool Visible, bool OpenedTransition); internal sealed class ScreenActivityReader { internal static readonly TimeSpan VisibilityExpiry = TimeSpan.FromSeconds(45.0); internal static readonly TimeSpan AdmissionMaxFutureSkew = TimeSpan.FromSeconds(10.0); internal const int SeenInstanceLruCapacity = 8; private readonly Func _generation; private readonly Func _session; private readonly string _path; private readonly Func _signature; private readonly Func _read; private readonly LocalFileRefreshGate _gate; private string? _stateGeneration; private string? _stateSession; private string? _instance; private long _sequence; private ScreenActivityScreen? _visible; private DateTimeOffset _acceptedAt; private readonly LinkedList _seen = new LinkedList(); internal ScreenActivityReader(Func expectedGeneration, Func expectedSession, string? path = null, Func? signatureReader = null, Func? readBytes = null, TimeSpan? forcedRecovery = null) { _generation = expectedGeneration; _session = expectedSession; _path = path ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "TVGS", "Schedule I", "HylandHelper", "bridge-screen-activity.json"); _signature = signatureReader ?? new Func(Signature); _read = readBytes ?? new Func(File.ReadAllBytes); _gate = new LocalFileRefreshGate(forcedRecovery ?? TimeSpan.FromSeconds(15.0)); } internal ScreenActivityObservation Observe(DateTimeOffset now) { string text = _generation(); string text2 = _session(); if (_stateGeneration != null && (_stateGeneration != text || _stateSession != text2)) { _visible = null; _instance = null; _sequence = 0L; _seen.Clear(); _stateGeneration = null; _stateSession = null; _gate.RejectTransient(); } if (_visible.HasValue && now - _acceptedAt > VisibilityExpiry) { _visible = null; } LocalFileSignature signature = _signature(_path); if (!_gate.ShouldRefresh(signature, now, dirty: false, out var _)) { return new ScreenActivityObservation(_visible.HasValue, OpenedTransition: false); } if (!signature.Exists || signature.Length <= 0 || signature.Length > 2048) { _gate.RejectTransient(); return new ScreenActivityObservation(_visible.HasValue, OpenedTransition: false); } byte[] utf; try { utf = _read(_path); } catch { _gate.RejectTransient(); return new ScreenActivityObservation(_visible.HasValue, OpenedTransition: false); } if (!ScreenActivitySerializer.TryParse(utf, out ScreenActivityDocument document, out string _)) { _gate.RejectTransient(); return new ScreenActivityObservation(_visible.HasValue, OpenedTransition: false); } if (document.PlaythroughGeneration != _generation() || document.RuntimeSession != _session() || !DateTimeOffset.TryParse(document.UpdatedAt, out var result) || result < now - VisibilityExpiry || result > now + AdmissionMaxFutureSkew) { _gate.RejectTransient(); return new ScreenActivityObservation(_visible.HasValue, OpenedTransition: false); } if (_stateGeneration != document.PlaythroughGeneration || _stateSession != document.RuntimeSession) { _instance = null; _sequence = 0L; _seen.Clear(); _stateGeneration = document.PlaythroughGeneration; _stateSession = document.RuntimeSession; } if (document.TileInstanceId == _instance && document.Sequence <= _sequence) { _gate.RejectTransient(); return new ScreenActivityObservation(_visible.HasValue, OpenedTransition: false); } if (document.TileInstanceId != _instance && _seen.Contains(document.TileInstanceId)) { _gate.RejectTransient(); return new ScreenActivityObservation(_visible.HasValue, OpenedTransition: false); } ScreenActivityScreen? screenActivityScreen = ScreenActivitySerializer.ParseScreen(document.VisibleScreen); bool openedTransition = screenActivityScreen.HasValue && screenActivityScreen != _visible; _visible = screenActivityScreen; _instance = document.TileInstanceId; _sequence = document.Sequence; _seen.Remove(document.TileInstanceId); _seen.AddLast(document.TileInstanceId); while (_seen.Count > 8) { _seen.RemoveFirst(); } _acceptedAt = now; _gate.Accept(signature, now); return new ScreenActivityObservation(_visible.HasValue, openedTransition); } internal void Invalidate() { _stateGeneration = (_stateSession = (_instance = null)); _sequence = 0L; _visible = null; _seen.Clear(); } private static LocalFileSignature Signature(string path) { try { FileInfo fileInfo = new FileInfo(path); return fileInfo.Exists ? new LocalFileSignature(Exists: true, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks) : LocalFileSignature.Missing; } catch { return LocalFileSignature.Missing; } } } internal enum ScreenActivityScreen { PhoneRecipes, AdvisorDispatches } internal sealed class ScreenActivityDocument { public const int SchemaVersionCurrent = 1; public const int MaxSerializedBytes = 2048; [JsonPropertyName("schemaVersion")] public int SchemaVersion { get; set; } = 1; [JsonPropertyName("playthroughGeneration")] public string PlaythroughGeneration { get; set; } = ""; [JsonPropertyName("runtimeSession")] public string RuntimeSession { get; set; } = ""; [JsonPropertyName("tileInstanceId")] public string TileInstanceId { get; set; } = ""; [JsonPropertyName("sequence")] public long Sequence { get; set; } [JsonPropertyName("updatedAt")] public string UpdatedAt { get; set; } = ""; [JsonPropertyName("visibleScreen")] public string? VisibleScreen { get; set; } } internal static class ScreenActivitySerializer { private static readonly JsonSerializerOptions Options = new JsonSerializerOptions(); private static readonly string[] Required = new string[6] { "schemaVersion", "playthroughGeneration", "runtimeSession", "tileInstanceId", "sequence", "updatedAt" }; private static readonly string[] Allowed = new string[7] { "schemaVersion", "playthroughGeneration", "runtimeSession", "tileInstanceId", "sequence", "updatedAt", "visibleScreen" }; internal static bool TrySerialize(ScreenActivityDocument document, out byte[] utf8, out string? reason) { utf8 = JsonSerializer.SerializeToUtf8Bytes(document, Options); if (utf8.Length > 2048) { utf8 = Array.Empty(); reason = "overBudget"; return false; } reason = null; return true; } internal static bool TryParse(byte[] utf8, out ScreenActivityDocument? document, out string? reason) { document = null; if (utf8 == null || utf8.Length == 0) { reason = "empty"; return false; } if (utf8.Length > 2048) { reason = "overBudget"; return false; } try { using JsonDocument jsonDocument = JsonDocument.Parse(utf8); JsonElement rootElement = jsonDocument.RootElement; if (rootElement.ValueKind != JsonValueKind.Object) { reason = "rootNotObject"; return false; } if (!ExactKeys(rootElement, out reason)) { return false; } if (!Int(rootElement, "schemaVersion", 1, 1, out var value) || !Text(rootElement, "playthroughGeneration", 1, 80, out string value2) || !IsGeneration(value2) || !Text(rootElement, "runtimeSession", 1, 64, out string value3) || !Text(rootElement, "tileInstanceId", 32, 32, out string value4) || !IsLowerHex(value4) || !Long(rootElement, "sequence", 1L, long.MaxValue, out var value5) || !Text(rootElement, "updatedAt", 1, 40, out string value6) || !DateTimeOffset.TryParse(value6, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var _)) { reason = "invalidField"; return false; } string visibleScreen = null; if (rootElement.TryGetProperty("visibleScreen", out var value7)) { if (value7.ValueKind == JsonValueKind.Null) { visibleScreen = null; } else { if (value7.ValueKind != JsonValueKind.String || !ParseScreen(value7.GetString()).HasValue) { reason = "visibleScreen"; return false; } visibleScreen = value7.GetString(); } } document = new ScreenActivityDocument { SchemaVersion = value, PlaythroughGeneration = value2, RuntimeSession = value3, TileInstanceId = value4, Sequence = value5, UpdatedAt = value6, VisibleScreen = visibleScreen }; reason = "ok"; return true; } catch (JsonException) { reason = "malformedJson"; return false; } } internal static ScreenActivityScreen? ParseScreen(string? value) { if (!(value == "phoneRecipes")) { if (value == "advisorDispatches") { return ScreenActivityScreen.AdvisorDispatches; } return null; } return ScreenActivityScreen.PhoneRecipes; } private static bool ExactKeys(JsonElement root, out string? reason) { HashSet hashSet = new HashSet(StringComparer.Ordinal); foreach (JsonProperty item2 in root.EnumerateObject()) { if (Array.IndexOf(Allowed, item2.Name) < 0) { reason = "unknownField"; return false; } if (!hashSet.Add(item2.Name)) { reason = "duplicateField"; return false; } } string[] required = Required; foreach (string item in required) { if (!hashSet.Contains(item)) { reason = "missingField"; return false; } } reason = null; return true; } private static bool Text(JsonElement root, string name, int min, int max, out string value) { value = ""; if (!root.TryGetProperty(name, out var value2) || value2.ValueKind != JsonValueKind.String) { return false; } value = value2.GetString() ?? ""; if (value.Trim().Length >= min) { return value.Length <= max; } return false; } private static bool Int(JsonElement root, string name, int min, int max, out int value) { value = 0; if (root.TryGetProperty(name, out var value2) && value2.ValueKind == JsonValueKind.Number && value2.TryGetInt32(out value) && value >= min) { return value <= max; } return false; } private static bool Long(JsonElement root, string name, long min, long max, out long value) { value = 0L; if (root.TryGetProperty(name, out var value2) && value2.ValueKind == JsonValueKind.Number && value2.TryGetInt64(out value) && value >= min) { return value <= max; } return false; } private static bool IsGeneration(string value) { if (value.Length == 67 && value.StartsWith("v2-", StringComparison.Ordinal)) { return IsLowerHex(value.Substring(3, value.Length - 3)); } return false; } private static bool IsLowerHex(string value) { for (int i = 0; i < value.Length; i++) { bool flag; switch (value[i]) { case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': case 'a': case 'b': case 'c': case 'd': case 'e': case 'f': flag = true; break; default: flag = false; break; } if (!flag) { return false; } } return true; } } internal enum SnapshotSendKind { Immediate, Cadence } internal sealed class SnapshotSchedule { private readonly TimeSpan _cadence; private DateTimeOffset _nextSnapshot = DateTimeOffset.MinValue; private string? _lastImmediateRequestId; private bool _immediatePending; public SnapshotSchedule(TimeSpan cadence) { _cadence = cadence; } public bool RequestImmediate(string requestId, DateTimeOffset now) { if (_immediatePending || string.Equals(_lastImmediateRequestId, requestId, StringComparison.Ordinal)) { return false; } _lastImmediateRequestId = requestId; _immediatePending = true; _nextSnapshot = now; return true; } public bool TryTakeDue(DateTimeOffset now, out SnapshotSendKind kind) { if (_immediatePending) { _immediatePending = false; _nextSnapshot = now + _cadence; kind = SnapshotSendKind.Immediate; return true; } if (now < _nextSnapshot) { kind = SnapshotSendKind.Immediate; return false; } _nextSnapshot = now + _cadence; kind = SnapshotSendKind.Cadence; return true; } public void Reset() { _nextSnapshot = DateTimeOffset.MinValue; _lastImmediateRequestId = null; _immediatePending = false; } } internal enum TileChallengeTransition { None, BecameReady, Withdrawn } internal sealed class TileChallengeStatus { [JsonPropertyName("version")] public int Version { get; init; } = 1; [JsonPropertyName("challenge")] public string Challenge { get; init; } = ""; [JsonPropertyName("protocolVersion")] public int ProtocolVersion { get; init; } = 1; } internal sealed class TileChallengeState { private sealed class Marker { [JsonPropertyName("version")] public int Version { get; set; } [JsonPropertyName("challenge")] public string Challenge { get; set; } = ""; [JsonPropertyName("tileBuild")] public string TileBuild { get; set; } = ""; [JsonPropertyName("protocolVersion")] public int ProtocolVersion { get; set; } } public const int MaxMarkerBytes = 4096; public const int MaxChallengeLength = 128; public const int MaxTileBuildLength = 128; private static readonly HashSet MarkerProperties = new HashSet(StringComparer.Ordinal) { "version", "challenge", "tileBuild", "protocolVersion" }; private readonly string _markerPath; public static string DefaultPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", "TVGS", "Schedule I", "HylandHelper", "bridge-tile-handshake.json"); public TileChallengeStatus Status { get; } public bool IsReady { get; private set; } public string? TileBuild { get; private set; } public TileChallengeState(string markerPath, Func? createChallenge = null) { _markerPath = markerPath; Status = new TileChallengeStatus { Challenge = (createChallenge ?? new Func(CreateChallenge))() }; if (!IsBounded(Status.Challenge, 128)) { throw new InvalidOperationException("Tile challenge generator returned an invalid value."); } } public TileChallengeTransition Refresh() { bool isReady = IsReady; Marker marker = ReadMarker(); IsReady = marker != null; TileBuild = marker?.TileBuild; if (!isReady && IsReady) { return TileChallengeTransition.BecameReady; } if (isReady && !IsReady) { return TileChallengeTransition.Withdrawn; } return TileChallengeTransition.None; } private Marker? ReadMarker() { try { if (!File.Exists(_markerPath)) { return null; } FileInfo fileInfo = new FileInfo(_markerPath); if (fileInfo.Length <= 0 || fileInfo.Length > 4096) { return null; } byte[] array = File.ReadAllBytes(_markerPath); if (array.Length == 0 || array.Length > 4096) { return null; } using JsonDocument jsonDocument = JsonDocument.Parse(array); JsonElement rootElement = jsonDocument.RootElement; if (rootElement.ValueKind != JsonValueKind.Object) { return null; } HashSet hashSet = new HashSet(StringComparer.Ordinal); foreach (JsonProperty item in rootElement.EnumerateObject()) { if (!MarkerProperties.Contains(item.Name) || !hashSet.Add(item.Name)) { return null; } } foreach (string markerProperty in MarkerProperties) { if (!rootElement.TryGetProperty(markerProperty, out var _)) { return null; } } Marker marker = JsonSerializer.Deserialize(array); if (marker == null || marker.Version != 1 || marker.ProtocolVersion != 1 || !string.Equals(marker.Challenge, Status.Challenge, StringComparison.Ordinal) || !IsBounded(marker.TileBuild, 128)) { return null; } return marker; } catch { return null; } } private static string CreateChallenge() { return Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); } private static bool IsBounded(string? value, int maxLength) { if (!string.IsNullOrWhiteSpace(value)) { return value.Length <= maxLength; } return false; } } }