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/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 a6bbd34..1463e43 100644 --- a/SVSimLoader/Plugin.cs +++ b/SVSimLoader/Plugin.cs @@ -49,6 +49,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 7712aa8..87fb5cf 100644 --- a/SVSimLoader/SvSimConfig.cs +++ b/SVSimLoader/SvSimConfig.cs @@ -7,6 +7,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; }