diff --git a/SVSimLoader/CaptureWriter.cs b/SVSimLoader/CaptureWriter.cs index 977f12f..03a7afa 100644 --- a/SVSimLoader/CaptureWriter.cs +++ b/SVSimLoader/CaptureWriter.cs @@ -15,6 +15,7 @@ internal static class CaptureWriter private static string _cardsPath; private static string _userDataPath; private static string _sbsPath; + private static string _spinProbePath; private static readonly HashSet _seenSbsIds = new HashSet(); private static ulong _lastSeenSteamId; @@ -33,6 +34,32 @@ internal static class CaptureWriter _cardsPath = Path.Combine(_sessionDir, "cards.json"); _userDataPath = Path.Combine(_sessionDir, "user-data.json"); _sbsPath = Path.Combine(_sessionDir, "special-battle-settings.ndjson"); + _spinProbePath = Path.Combine(_sessionDir, "spin-rng.ndjson"); + } + + /// + /// Append one row of the spin/shared-RNG diagnostic (EnableSpinProbe). `event` is "receive" + /// (OperateReceive.StartOperate, logged before the spin crank) or "send" (NetworkBattleSender.EmitMsg). + /// `spin` is the inbound crank count (receive only; -1 when not applicable) and `count` is the + /// cumulative BattleManagerBase.stableRandomCount tally (-1 when no battle manager is live). + /// Offline, per-turn `count` deltas vs `spin` settle whether prod authors spin from server-side + /// simulation or it is derivable from the active player's wire data. See + /// docs/audits/battle-node-spin-rng-model-2026-06-04.md. + /// + public static void AppendSpinProbe(string eventKind, string uri, int spin, int count) + { + string line = JsonMapper.ToJson(new Dictionary + { + { "ts", DateTime.UtcNow.ToString("o") }, + { "event", eventKind }, + { "uri", uri }, + { "spin", spin }, + { "count", count }, + }); + lock (_lock) + { + File.AppendAllText(_spinProbePath, line + "\n"); + } } private static string ExtractHost(string url) diff --git a/SVSimLoader/InstanceIdentityStore.cs b/SVSimLoader/InstanceIdentityStore.cs new file mode 100644 index 0000000..219fa9c --- /dev/null +++ b/SVSimLoader/InstanceIdentityStore.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.IO; + +namespace SVSimLoader +{ + /// + /// File-backed store for the three account-identity PlayerPrefs keys (UDID, VIEWER_ID, + /// SHORT_UDID), so a second client instance on the same machine gets its own identity + /// instead of sharing the per-Windows-user registry store. Only these three keys are + /// redirected; everything else (RES_VER, language, asset cache) stays in the shared store. + /// Persisted as dependency-free key=value lines — the values are a GUID and two integers, + /// which never contain '=' or newlines. + /// + public static class InstanceIdentityStore + { + public static readonly string[] Keys = { "UDID", "VIEWER_ID", "SHORT_UDID" }; + + private static string _path; + private static readonly Dictionary _values = new Dictionary(); + + public static void Initialize(string path) + { + _path = path; + _values.Clear(); + if (!File.Exists(_path)) return; + foreach (var line in File.ReadAllLines(_path)) + { + int eq = line.IndexOf('='); + if (eq <= 0) continue; + string key = line.Substring(0, eq); + string val = line.Substring(eq + 1); + if (System.Array.IndexOf(Keys, key) >= 0) _values[key] = val; + } + } + + public static bool TryGet(string key, out string value) => _values.TryGetValue(key, out value); + + public static void Set(string key, string value) + { + _values[key] = value; + Save(); + } + + private static void Save() + { + var lines = new List(); + foreach (var kv in _values) lines.Add(kv.Key + "=" + kv.Value); + File.WriteAllLines(_path, lines.ToArray()); + } + } +} diff --git a/SVSimLoader/Patches/MultiInstancePatches.cs b/SVSimLoader/Patches/MultiInstancePatches.cs new file mode 100644 index 0000000..d0c3bf5 --- /dev/null +++ b/SVSimLoader/Patches/MultiInstancePatches.cs @@ -0,0 +1,74 @@ +using Cute; +using HarmonyLib; + +namespace SVSimLoader.Patches; + +[HarmonyPatch] +public static class MultiInstancePatches +{ + // (1) Defeat the machine-wide single-instance guard. createMutex is private, so target it + // by string name. Prefix returning false skips the original (Application.Quit never runs). + [HarmonyPatch(typeof(BootApp), "createMutex")] + [HarmonyPrefix] + public static bool SkipMutex() + { + if (!SvSimConfig.SecondaryInstance) return true; // primary/normal: run original + Plugin.Log.LogWarning("Multi-instance: skipping BootApp.createMutex single-instance guard."); + return false; + } + + // (2) Redirect the three identity keys to the per-instance file store. Other keys fall + // through to the original (return true). + private static bool IsIdentityKey(string key) => + key == "UDID" || key == "VIEWER_ID" || key == "SHORT_UDID"; + + [HarmonyPatch(typeof(SavedataManager), nameof(SavedataManager.GetString))] + [HarmonyPrefix] + public static bool GetString(string key, string defaultValue, ref string __result) + { + if (!SvSimConfig.SecondaryInstance || !IsIdentityKey(key)) return true; + __result = InstanceIdentityStore.TryGet(key, out var v) ? v : defaultValue; + return false; + } + + [HarmonyPatch(typeof(SavedataManager), nameof(SavedataManager.SetString))] + [HarmonyPrefix] + public static bool SetString(string key, string value) + { + if (!SvSimConfig.SecondaryInstance || !IsIdentityKey(key)) return true; + InstanceIdentityStore.Set(key, value); + return false; + } + + [HarmonyPatch(typeof(SavedataManager), nameof(SavedataManager.GetInt))] + [HarmonyPrefix] + public static bool GetInt(string key, int defaultValue, ref int __result) + { + if (!SvSimConfig.SecondaryInstance || !IsIdentityKey(key)) return true; + __result = InstanceIdentityStore.TryGet(key, out var v) && int.TryParse(v, out var i) ? i : defaultValue; + return false; + } + + [HarmonyPatch(typeof(SavedataManager), nameof(SavedataManager.SetInt))] + [HarmonyPrefix] + public static bool SetInt(string key, int value) + { + if (!SvSimConfig.SecondaryInstance || !IsIdentityKey(key)) return true; + int v = value; + InstanceIdentityStore.Set(key, v.ToString()); + return false; + } + + // (3) Force a synthetic Steam identity. setSTEAMPlatformData runs in Certification.Start(); + // a postfix overwrites SteamID/SteamSessionTicket (private setters) via Traverse. + [HarmonyPatch(typeof(Certification), "setSTEAMPlatformData")] + [HarmonyPostfix] + public static void ForceSteamIdentity() + { + if (!SvSimConfig.SecondaryInstance) return; + Traverse.Create(typeof(Certification)).Property("SteamID").SetValue(SvSimConfig.FakeSteamId); + Traverse.Create(typeof(Certification)).Property("SteamSessionTicket").SetValue(SvSimConfig.FakeTicket); + Plugin.Log.LogWarning( + $"Multi-instance: forced SteamID={SvSimConfig.FakeSteamId}, ticket={SvSimConfig.FakeTicket}."); + } +} diff --git a/SVSimLoader/Patches/SpinProbe.cs b/SVSimLoader/Patches/SpinProbe.cs new file mode 100644 index 0000000..3eef597 --- /dev/null +++ b/SVSimLoader/Patches/SpinProbe.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; + +namespace SVSimLoader.Patches; + +/// +/// Diagnostic for the battle-node `spin` investigation (gated on EnableSpinProbe). It records, per +/// battle frame, the cumulative shared-RNG draw tally (BattleManagerBase.stableRandomCount) alongside +/// the inbound `spin` crank count, so per-turn `count` deltas can be compared against `spin` offline. +/// +/// Why this settles the open question: every shared-RNG draw funnels through StableRandom/ +/// StableRandomDouble, which increment stableRandomCount — so that field is an EXACT local draw count +/// (decomp: BattleManagerBase.cs:1581,1592; the only `_stableRandom.` callers). The receiver cranks the +/// shared RNG `spin` times before dispatching each frame (OperateReceive.StartOperate:80-84). If our own +/// turns advance `count` by ~the magnitude of the `spin` the opponent's turns hand us, the draws are +/// client-side and `spin` is plausibly wire-derivable; if our local deltas stay near zero while inbound +/// `spin` is in the tens–hundreds, prod authors `spin` from server-side simulation (≈ an engine). +/// +/// Run against PROD (servers live until end of June 2026) to capture real `spin`; a local Bot/AI battle +/// only ever sends spin=0. Output: spin-rng.ndjson in the capture session dir. +/// See docs/audits/battle-node-spin-rng-model-2026-06-04.md. +/// +[HarmonyPatch] +public static class SpinProbe +{ + // stableRandomCount is a private int on BattleManagerBase — read it reflectively. + private static readonly FieldInfo CountField = + AccessTools.Field(typeof(BattleManagerBase), "stableRandomCount"); + + /// Current cumulative shared-RNG draw tally, or -1 when no battle manager is live / unreadable. + private static int ReadCount() + { + try + { + var mgr = BattleManagerBase.GetIns(); + if (mgr == null || CountField == null) return -1; + return (int)CountField.GetValue(mgr); + } + catch + { + return -1; + } + } + + // Receive side: logged BEFORE the spin crank runs, so `count` is the tally entering this frame and + // `spin` is the value about to be applied. + [HarmonyPatch(typeof(OperateReceive), nameof(OperateReceive.StartOperate))] + [HarmonyPrefix] + public static void OnReceiveFrame(NetworkBattleReceiver.ReceiveData receivedData) + { + if (!SvSimConfig.EnableSpinProbe || receivedData == null) return; + try + { + CaptureWriter.AppendSpinProbe("receive", $"{receivedData.dataUri}", receivedData.spin, ReadCount()); + } + catch (Exception e) + { + Plugin.Log.LogError(e); + } + } + + // Send side: our own emitted frames. `count` shows how much our turn's actions advanced the tally; + // `spin` is not applicable on a send (-1). + [HarmonyPatch(typeof(NetworkBattleSender), "EmitMsg")] + [HarmonyPrefix] + public static void OnSendFrame(NetworkBattleDefine.NetworkBattleURI uri, Dictionary dataList = null) + { + if (!SvSimConfig.EnableSpinProbe) return; + try + { + CaptureWriter.AppendSpinProbe("send", $"{uri}", -1, ReadCount()); + } + catch (Exception e) + { + Plugin.Log.LogError(e); + } + } +} diff --git a/SVSimLoader/Plugin.cs b/SVSimLoader/Plugin.cs index ce49bd5..9890a8b 100644 --- a/SVSimLoader/Plugin.cs +++ b/SVSimLoader/Plugin.cs @@ -19,6 +19,25 @@ namespace SVSimLoader { Instance = this; Log = base.Logger; + var instanceRaw = System.Environment.GetEnvironmentVariable("SVSIM_INSTANCE_ID"); + if (!string.IsNullOrEmpty(instanceRaw) && int.TryParse(instanceRaw, out var instanceId) && instanceId > 0) + { + SvSimConfig.SecondaryInstance = true; + SvSimConfig.InstanceId = instanceId; + SvSimConfig.FakeSteamId = 900000UL + (ulong)instanceId; + // Ticket must be non-empty, even-length, valid hex (the server HexDecodes it before + // the bypass) and DISTINCT per instance (SteamSessionService caches ticket->steamId + // and rejects a reused ticket under a different steamId). + string hex = SvSimConfig.FakeSteamId.ToString("x"); + if (hex.Length % 2 == 1) hex = "0" + hex; + SvSimConfig.FakeTicket = hex; + SvSimConfig.IdentityFilePath = + System.IO.Path.Combine(Paths.ConfigPath, $"svsim-identity-{instanceId}.json"); + InstanceIdentityStore.Initialize(SvSimConfig.IdentityFilePath); + Logger.LogWarning( + $"MULTI-INSTANCE MODE: id={instanceId}, fakeSteamId={SvSimConfig.FakeSteamId}, " + + $"identity file={SvSimConfig.IdentityFilePath}. Single-instance mutex will be skipped."); + } // Plugin startup logic Logger.LogInfo($"Plugin {PluginInfo.PLUGIN_GUID} is loaded!"); _applicationUrl = Config.Bind("Connection", "ApplicationUrl", "https://utoongaize.shadowverse.jp/shadowverse/", @@ -35,6 +54,9 @@ namespace SVSimLoader SvSimConfig.EnableBattleCapture = Config.Bind("Capture", "EnableBattleCapture", true, "Write each Socket.IO battle send/receive body to battle-traffic.ndjson in the current capture session directory").Value; + SvSimConfig.EnableSpinProbe = + Config.Bind("Capture", "EnableSpinProbe", false, + "Diagnostic for the battle-node `spin` investigation. On every received battle frame (OperateReceive.StartOperate) and every emitted frame (NetworkBattleSender.EmitMsg), append {event, uri, spin, count} to spin-rng.ndjson, where `count` is BattleManagerBase.stableRandomCount (the cumulative shared-RNG draw tally, read before the spin crank). Lets us compare per-turn local draw deltas against the inbound spin to settle whether prod authors spin from server-side simulation or it is wire-derivable. Run against PROD (servers live until end of June 2026) to capture real spin values. See docs/audits/battle-node-spin-rng-model-2026-06-04.md.").Value; SvSimConfig.DumpCardDB = Config.Bind("Capture", "DumpCardDB", false, "Dumps the loaded card master to cards.json in the current capture session directory").Value; diff --git a/SVSimLoader/SvSimConfig.cs b/SVSimLoader/SvSimConfig.cs index b6298f9..fb121b0 100644 --- a/SVSimLoader/SvSimConfig.cs +++ b/SVSimLoader/SvSimConfig.cs @@ -8,6 +8,7 @@ public static class SvSimConfig public static bool DumpCardDB { get; set; } public static bool EnableTrafficCapture { get; set; } public static bool EnableBattleCapture { get; set; } + public static bool EnableSpinProbe { get; set; } public static bool DumpUserData { get; set; } public static bool SweepGachaExchange { get; set; } public static float GachaExchangeSweepPacingSeconds { get; set; } @@ -20,4 +21,11 @@ public static class SvSimConfig public static bool ProbeEventSection { get; set; } public static string StorySectionIdFilter { get; set; } public static bool NukeIdentityOnStartup { get; set; } + + // Multi-instance (same-machine two-client PvP smoke) — driven by the SVSIM_INSTANCE_ID env var. + public static bool SecondaryInstance { get; set; } // true when SVSIM_INSTANCE_ID is set + public static int InstanceId { get; set; } // parsed env value + public static string IdentityFilePath { get; set; } // per-instance identity file + public static ulong FakeSteamId { get; set; } // 900000 + InstanceId + public static string FakeTicket { get; set; } // even-length hex of FakeSteamId }