Compare commits
6 Commits
b544f0f9eb
...
8d86b45135
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d86b45135 | ||
|
|
b3df361f6d | ||
|
|
620ae31582 | ||
|
|
800e40344b | ||
|
|
5bc7d6f184 | ||
|
|
f0c422aa8f |
@@ -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<string> _seenSbsIds = new HashSet<string>();
|
||||
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");
|
||||
}
|
||||
|
||||
/// <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)
|
||||
|
||||
51
SVSimLoader/InstanceIdentityStore.cs
Normal file
51
SVSimLoader/InstanceIdentityStore.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace SVSimLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class InstanceIdentityStore
|
||||
{
|
||||
public static readonly string[] Keys = { "UDID", "VIEWER_ID", "SHORT_UDID" };
|
||||
|
||||
private static string _path;
|
||||
private static readonly Dictionary<string, string> _values = new Dictionary<string, string>();
|
||||
|
||||
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<string>();
|
||||
foreach (var kv in _values) lines.Add(kv.Key + "=" + kv.Value);
|
||||
File.WriteAllLines(_path, lines.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
74
SVSimLoader/Patches/MultiInstancePatches.cs
Normal file
74
SVSimLoader/Patches/MultiInstancePatches.cs
Normal file
@@ -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}.");
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user