feat(loader): add EnableSpinProbe diagnostic for the spin investigation
Logs {event,uri,spin,count} per battle frame to spin-rng.ndjson, where
count is BattleManagerBase.stableRandomCount (cumulative shared-RNG draws,
read before the spin crank). Lets us compare per-turn local draw deltas
against the inbound spin to determine whether prod authors spin from
server-side simulation or it is wire-derivable bookkeeping.
- SpinProbe.cs: HarmonyPrefix on OperateReceive.StartOperate (receive,
pre-crank) and NetworkBattleSender.EmitMsg (send); stableRandomCount
read reflectively via AccessTools.
- CaptureWriter.AppendSpinProbe -> spin-rng.ndjson.
- SvSimConfig.EnableSpinProbe + Plugin.cs Config.Bind (default off).
See docs/audits/battle-node-spin-rng-model-2026-06-04.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ internal static class CaptureWriter
|
|||||||
private static string _cardsPath;
|
private static string _cardsPath;
|
||||||
private static string _userDataPath;
|
private static string _userDataPath;
|
||||||
private static string _sbsPath;
|
private static string _sbsPath;
|
||||||
|
private static string _spinProbePath;
|
||||||
private static readonly HashSet<string> _seenSbsIds = new HashSet<string>();
|
private static readonly HashSet<string> _seenSbsIds = new HashSet<string>();
|
||||||
private static ulong _lastSeenSteamId;
|
private static ulong _lastSeenSteamId;
|
||||||
|
|
||||||
@@ -33,6 +34,32 @@ internal static class CaptureWriter
|
|||||||
_cardsPath = Path.Combine(_sessionDir, "cards.json");
|
_cardsPath = Path.Combine(_sessionDir, "cards.json");
|
||||||
_userDataPath = Path.Combine(_sessionDir, "user-data.json");
|
_userDataPath = Path.Combine(_sessionDir, "user-data.json");
|
||||||
_sbsPath = Path.Combine(_sessionDir, "special-battle-settings.ndjson");
|
_sbsPath = Path.Combine(_sessionDir, "special-battle-settings.ndjson");
|
||||||
|
_spinProbePath = Path.Combine(_sessionDir, "spin-rng.ndjson");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static void AppendSpinProbe(string eventKind, string uri, int spin, int count)
|
||||||
|
{
|
||||||
|
string line = JsonMapper.ToJson(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "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)
|
private static string ExtractHost(string url)
|
||||||
|
|||||||
80
SVSimLoader/Patches/SpinProbe.cs
Normal file
80
SVSimLoader/Patches/SpinProbe.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Reflection;
|
||||||
|
using HarmonyLib;
|
||||||
|
|
||||||
|
namespace SVSimLoader.Patches;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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");
|
||||||
|
|
||||||
|
/// <summary>Current cumulative shared-RNG draw tally, or -1 when no battle manager is live / unreadable.</summary>
|
||||||
|
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<string, object> dataList = null)
|
||||||
|
{
|
||||||
|
if (!SvSimConfig.EnableSpinProbe) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CaptureWriter.AppendSpinProbe("send", $"{uri}", -1, ReadCount());
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Plugin.Log.LogError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,9 @@ namespace SVSimLoader
|
|||||||
SvSimConfig.EnableBattleCapture =
|
SvSimConfig.EnableBattleCapture =
|
||||||
Config.Bind("Capture", "EnableBattleCapture", true,
|
Config.Bind("Capture", "EnableBattleCapture", true,
|
||||||
"Write each Socket.IO battle send/receive body to battle-traffic.ndjson in the current capture session directory").Value;
|
"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 =
|
SvSimConfig.DumpCardDB =
|
||||||
Config.Bind("Capture", "DumpCardDB", false,
|
Config.Bind("Capture", "DumpCardDB", false,
|
||||||
"Dumps the loaded card master to cards.json in the current capture session directory").Value;
|
"Dumps the loaded card master to cards.json in the current capture session directory").Value;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public static class SvSimConfig
|
|||||||
public static bool DumpCardDB { get; set; }
|
public static bool DumpCardDB { get; set; }
|
||||||
public static bool EnableTrafficCapture { get; set; }
|
public static bool EnableTrafficCapture { get; set; }
|
||||||
public static bool EnableBattleCapture { get; set; }
|
public static bool EnableBattleCapture { get; set; }
|
||||||
|
public static bool EnableSpinProbe { get; set; }
|
||||||
public static bool DumpUserData { get; set; }
|
public static bool DumpUserData { get; set; }
|
||||||
public static bool SweepGachaExchange { get; set; }
|
public static bool SweepGachaExchange { get; set; }
|
||||||
public static float GachaExchangeSweepPacingSeconds { get; set; }
|
public static float GachaExchangeSweepPacingSeconds { get; set; }
|
||||||
|
|||||||
Reference in New Issue
Block a user