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>
478 lines
19 KiB
C#
478 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using BepInEx;
|
|
using LitJson;
|
|
|
|
namespace SVSimLoader;
|
|
|
|
internal static class CaptureWriter
|
|
{
|
|
private static readonly object _lock = new object();
|
|
private static string _sessionDir;
|
|
private static string _trafficPath;
|
|
private static string _battleTrafficPath;
|
|
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;
|
|
|
|
public static string SessionDirectory => _sessionDir;
|
|
|
|
public static void Initialize()
|
|
{
|
|
string root = Path.Combine(Paths.BepInExRootPath, "svsim-captures");
|
|
string timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
|
|
string host = SanitizeForPath(ExtractHost(SvSimConfig.ApplicationUrl));
|
|
string session = string.IsNullOrEmpty(host) ? timestamp : $"{timestamp}_{host}";
|
|
_sessionDir = Path.Combine(root, session);
|
|
Directory.CreateDirectory(_sessionDir);
|
|
_trafficPath = Path.Combine(_sessionDir, "traffic.ndjson");
|
|
_battleTrafficPath = Path.Combine(_sessionDir, "battle-traffic.ndjson");
|
|
_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)
|
|
{
|
|
if (string.IsNullOrEmpty(url)) return null;
|
|
try { return new Uri(url).Host; }
|
|
catch { return null; }
|
|
}
|
|
|
|
private static string SanitizeForPath(string s)
|
|
{
|
|
if (string.IsNullOrEmpty(s)) return s;
|
|
foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
|
|
return s;
|
|
}
|
|
|
|
public static void WriteCards(string json)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
File.WriteAllText(_cardsPath, json);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Append a single special_battle_setting payload to special-battle-settings.ndjson,
|
|
/// keyed-deduped by sbs id (in-memory, per session). Story chapters reuse sbs rows
|
|
/// heavily (one row spans many chapters across many classes), so dedup keeps the
|
|
/// file compact. Bootstrap-side join with /info captures resolves the
|
|
/// chapter → sbs_id mapping separately.
|
|
/// </summary>
|
|
public static void AppendSpecialBattleSetting(string sbsId, JsonData sbsPayload)
|
|
{
|
|
if (string.IsNullOrEmpty(sbsId) || sbsPayload == null) return;
|
|
lock (_lock)
|
|
{
|
|
if (!_seenSbsIds.Add(sbsId)) return;
|
|
var ts = DateTime.UtcNow.ToString("o");
|
|
var line = "{\"ts\":\"" + ts
|
|
+ "\",\"id\":\"" + EscapeJsonString(sbsId)
|
|
+ "\",\"payload\":" + sbsPayload.ToJson() + "}";
|
|
File.AppendAllText(_sbsPath, line + "\n");
|
|
}
|
|
}
|
|
|
|
private static string EscapeJsonString(string s)
|
|
{
|
|
return s.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
|
}
|
|
|
|
public static void AppendTraffic(string direction, string url, bool encrypted, string body)
|
|
{
|
|
string envelope = JsonMapper.ToJson(new Dictionary<string, object>
|
|
{
|
|
{ "ts", DateTime.UtcNow.ToString("o") },
|
|
{ "direction", direction },
|
|
{ "url", url },
|
|
{ "encrypted", encrypted },
|
|
});
|
|
AppendLineWithBody(_trafficPath, envelope, body);
|
|
}
|
|
|
|
public static void AppendBattleTraffic(string direction, string uri, string body)
|
|
{
|
|
string envelope = JsonMapper.ToJson(new Dictionary<string, object>
|
|
{
|
|
{ "ts", DateTime.UtcNow.ToString("o") },
|
|
{ "direction", direction },
|
|
{ "uri", uri },
|
|
});
|
|
AppendLineWithBody(_battleTrafficPath, envelope, body);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Track the most recent steam_id seen on an outgoing request body. We need it to enrich
|
|
/// the user-data dump (/load/index response doesn't carry steam_id but the import endpoint
|
|
/// needs it to link the viewer).
|
|
/// </summary>
|
|
public static void RecordSteamId(ulong steamId)
|
|
{
|
|
if (steamId != 0) _lastSeenSteamId = steamId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extract essential viewer fields from a /load/index response and write them as a clean
|
|
/// JSON file matching the /admin/import_viewer request shape. Skipped silently when no
|
|
/// steam_id has been seen yet (need at least one prior request to know the link).
|
|
/// </summary>
|
|
public static void WriteUserDataFromLoadIndex(JsonData loadIndexData)
|
|
{
|
|
if (_lastSeenSteamId == 0)
|
|
{
|
|
Plugin.Log?.LogWarning("DumpUserData: no steam_id seen yet, skipping user-data dump.");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
// SetResponseData hands us the FULL response envelope { data_headers, data }; the
|
|
// viewer payload (user_info, user_crystal_count, user_card_list, ...) lives under the
|
|
// inner `data` key. Descend into it before extracting — same as
|
|
// ExaminationPatches.TryExtractSpecialBattleSettings does. Without this every SafeGet
|
|
// below misses and the dump contains nothing but steam_id. The inner payload has no
|
|
// top-level `data` key of its own, so this is safe if a caller ever pre-strips it.
|
|
if (loadIndexData != null && loadIndexData.IsObject && loadIndexData.Keys.Contains("data"))
|
|
{
|
|
loadIndexData = loadIndexData["data"];
|
|
}
|
|
|
|
var dump = new Dictionary<string, object>
|
|
{
|
|
{ "steam_id", _lastSeenSteamId }
|
|
};
|
|
|
|
var userInfo = SafeGet(loadIndexData, "user_info");
|
|
if (userInfo != null)
|
|
{
|
|
Copy(userInfo, "name", dump, "display_name");
|
|
Copy(userInfo, "country_code", dump, "country_code");
|
|
Copy(userInfo, "selected_emblem_id", dump, "selected_emblem_id");
|
|
Copy(userInfo, "selected_degree_id", dump, "selected_degree_id");
|
|
}
|
|
|
|
var tutorial = SafeGet(loadIndexData, "user_tutorial");
|
|
if (tutorial != null) Copy(tutorial, "tutorial_step", dump, "tutorial_state");
|
|
|
|
var crystal = SafeGet(loadIndexData, "user_crystal_count");
|
|
if (crystal != null)
|
|
{
|
|
var cur = new Dictionary<string, object>();
|
|
Copy(crystal, "crystal", cur, "crystals");
|
|
Copy(crystal, "rupy", cur, "rupees");
|
|
Copy(crystal, "red_ether", cur, "red_ether");
|
|
if (cur.Count > 0) dump["currency"] = cur;
|
|
}
|
|
|
|
ExtractIdArray(loadIndexData, "user_sleeve_list", "sleeve_id", dump, "owned_sleeve_ids");
|
|
ExtractIdArray(loadIndexData, "user_emblem_list", "emblem_id", dump, "owned_emblem_ids");
|
|
ExtractIdArray(loadIndexData, "user_degree_list", "degree_id", dump, "owned_degree_ids");
|
|
ExtractMyPageList(loadIndexData, dump);
|
|
ExtractOwnedLeaderSkins(loadIndexData, dump);
|
|
ExtractClasses(loadIndexData, dump);
|
|
ExtractOwnedCards(loadIndexData, dump);
|
|
ExtractItems(loadIndexData, dump);
|
|
ExtractDecks(loadIndexData, dump);
|
|
|
|
lock (_lock)
|
|
{
|
|
File.WriteAllText(_userDataPath, JsonMapper.ToJson(dump));
|
|
}
|
|
Plugin.Log?.LogInfo($"Dumped user data to user-data.json (steam_id={_lastSeenSteamId}).");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Plugin.Log?.LogError($"DumpUserData failed: {e}");
|
|
}
|
|
}
|
|
|
|
private static JsonData SafeGet(JsonData data, string key)
|
|
{
|
|
if (data == null || !data.IsObject) return null;
|
|
try
|
|
{
|
|
return data.Keys.Contains(key) ? data[key] : null;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static void Copy(JsonData source, string srcKey, Dictionary<string, object> dest, string destKey)
|
|
{
|
|
var val = SafeGet(source, srcKey);
|
|
if (val == null) return;
|
|
if (val.IsInt) dest[destKey] = (int)val;
|
|
else if (val.IsLong) dest[destKey] = (long)val;
|
|
else if (val.IsString) dest[destKey] = (string)val;
|
|
else if (val.IsBoolean) dest[destKey] = (bool)val;
|
|
else if (val.IsDouble) dest[destKey] = (double)val;
|
|
}
|
|
|
|
private static void ExtractIdArray(JsonData data, string listKey, string idField,
|
|
Dictionary<string, object> dump, string destKey)
|
|
{
|
|
var list = SafeGet(data, listKey);
|
|
if (list == null || !list.IsArray) return;
|
|
var ids = new List<int>();
|
|
for (int i = 0; i < list.Count; i++)
|
|
{
|
|
var entry = list[i];
|
|
var idVal = SafeGet(entry, idField);
|
|
if (idVal == null) continue;
|
|
if (idVal.IsInt) ids.Add((int)idVal);
|
|
else if (idVal.IsLong) ids.Add((int)(long)idVal);
|
|
}
|
|
if (ids.Count > 0) dump[destKey] = ids;
|
|
}
|
|
|
|
private static void ExtractMyPageList(JsonData data, Dictionary<string, object> dump)
|
|
{
|
|
// Wire shape is string[] per the audit (LoadDetail.cs:387-392 calls .ToString()).
|
|
var list = SafeGet(data, "user_mypage_list");
|
|
if (list == null || !list.IsArray) return;
|
|
var ids = new List<int>();
|
|
for (int i = 0; i < list.Count; i++)
|
|
{
|
|
var entry = list[i];
|
|
if (entry == null) continue;
|
|
if (entry.IsInt) ids.Add((int)entry);
|
|
else if (entry.IsLong) ids.Add((int)(long)entry);
|
|
else if (entry.IsString && int.TryParse((string)entry, out int parsed)) ids.Add(parsed);
|
|
}
|
|
if (ids.Count > 0) dump["owned_mypage_background_ids"] = ids;
|
|
}
|
|
|
|
private static void ExtractOwnedLeaderSkins(JsonData data, Dictionary<string, object> dump)
|
|
{
|
|
var list = SafeGet(data, "user_leader_skin_list");
|
|
if (list == null || !list.IsArray) return;
|
|
var ids = new List<int>();
|
|
for (int i = 0; i < list.Count; i++)
|
|
{
|
|
var entry = list[i];
|
|
var isOwned = SafeGet(entry, "is_owned");
|
|
bool owned = isOwned != null && (
|
|
(isOwned.IsBoolean && (bool)isOwned) ||
|
|
(isOwned.IsInt && (int)isOwned != 0) ||
|
|
(isOwned.IsLong && (long)isOwned != 0));
|
|
if (!owned) continue;
|
|
var idVal = SafeGet(entry, "leader_skin_id");
|
|
if (idVal == null) continue;
|
|
if (idVal.IsInt) ids.Add((int)idVal);
|
|
else if (idVal.IsLong) ids.Add((int)(long)idVal);
|
|
}
|
|
if (ids.Count > 0) dump["owned_leader_skin_ids"] = ids;
|
|
}
|
|
|
|
private static void ExtractClasses(JsonData data, Dictionary<string, object> dump)
|
|
{
|
|
var list = SafeGet(data, "user_class_list");
|
|
if (list == null || !list.IsArray) return;
|
|
var classes = new List<Dictionary<string, object>>();
|
|
for (int i = 0; i < list.Count; i++)
|
|
{
|
|
var entry = list[i];
|
|
var classId = SafeGet(entry, "class_id");
|
|
if (classId == null) continue;
|
|
var c = new Dictionary<string, object>();
|
|
if (classId.IsInt) c["class_id"] = (int)classId;
|
|
else if (classId.IsLong) c["class_id"] = (int)(long)classId;
|
|
else continue;
|
|
Copy(entry, "level", c, "level");
|
|
Copy(entry, "exp", c, "exp");
|
|
classes.Add(c);
|
|
}
|
|
if (classes.Count > 0) dump["classes"] = classes;
|
|
}
|
|
|
|
private static void ExtractOwnedCards(JsonData data, Dictionary<string, object> dump)
|
|
{
|
|
var list = SafeGet(data, "user_card_list");
|
|
if (list == null || !list.IsArray) return;
|
|
var cards = new List<Dictionary<string, object>>();
|
|
for (int i = 0; i < list.Count; i++)
|
|
{
|
|
var entry = list[i];
|
|
var idVal = SafeGet(entry, "card_id");
|
|
if (idVal == null) continue;
|
|
long cardId;
|
|
if (idVal.IsInt) cardId = (int)idVal;
|
|
else if (idVal.IsLong) cardId = (long)idVal;
|
|
else continue;
|
|
|
|
var c = new Dictionary<string, object> { { "card_id", cardId } };
|
|
|
|
var num = SafeGet(entry, "number");
|
|
if (num != null)
|
|
{
|
|
if (num.IsInt) c["count"] = (int)num;
|
|
else if (num.IsLong) c["count"] = (int)(long)num;
|
|
}
|
|
|
|
var prot = SafeGet(entry, "is_protected");
|
|
if (prot != null)
|
|
{
|
|
c["is_protected"] =
|
|
(prot.IsBoolean && (bool)prot) ||
|
|
(prot.IsInt && (int)prot != 0) ||
|
|
(prot.IsLong && (long)prot != 0);
|
|
}
|
|
cards.Add(c);
|
|
}
|
|
if (cards.Count > 0) dump["owned_cards"] = cards;
|
|
}
|
|
|
|
private static void ExtractItems(JsonData data, Dictionary<string, object> dump)
|
|
{
|
|
var list = SafeGet(data, "user_item_list");
|
|
if (list == null || !list.IsArray) return;
|
|
var items = new List<Dictionary<string, object>>();
|
|
for (int i = 0; i < list.Count; i++)
|
|
{
|
|
var entry = list[i];
|
|
var idVal = SafeGet(entry, "item_id");
|
|
if (idVal == null) continue;
|
|
var item = new Dictionary<string, object>();
|
|
if (idVal.IsInt) item["item_id"] = (int)idVal;
|
|
else if (idVal.IsLong) item["item_id"] = (int)(long)idVal;
|
|
else continue;
|
|
var num = SafeGet(entry, "number");
|
|
if (num != null)
|
|
{
|
|
if (num.IsInt) item["count"] = (int)num;
|
|
else if (num.IsLong) item["count"] = (int)(long)num;
|
|
}
|
|
items.Add(item);
|
|
}
|
|
if (items.Count > 0) dump["items"] = items;
|
|
}
|
|
|
|
// /load/index splits decks into one container per format; the format is the KEY, not a
|
|
// per-deck field. Values mirror the wire deck_format codes (Wizard/Data.cs FormatConvertApi).
|
|
private struct DeckFormatKey
|
|
{
|
|
public string Key;
|
|
public int Format;
|
|
public DeckFormatKey(string key, int format) { Key = key; Format = format; }
|
|
}
|
|
|
|
private static readonly DeckFormatKey[] DeckFormatKeys =
|
|
{
|
|
new DeckFormatKey("user_deck_rotation", 1),
|
|
new DeckFormatKey("user_deck_unlimited", 2),
|
|
new DeckFormatKey("user_deck_pre_rotation", 3),
|
|
new DeckFormatKey("user_deck_crossover", 4),
|
|
new DeckFormatKey("user_deck_my_rotation", 5),
|
|
};
|
|
|
|
private static void ExtractDecks(JsonData data, Dictionary<string, object> dump)
|
|
{
|
|
var decks = new List<Dictionary<string, object>>();
|
|
foreach (var fmt in DeckFormatKeys)
|
|
{
|
|
var container = SafeGet(data, fmt.Key);
|
|
var deckList = SafeGet(container, "user_deck_list");
|
|
if (deckList == null || !deckList.IsArray) continue;
|
|
|
|
for (int i = 0; i < deckList.Count; i++)
|
|
{
|
|
var entry = deckList[i];
|
|
// /load/index ships every deck slot, most of them empty placeholders. Skip the
|
|
// empty ones — the import drops them anyway, and it keeps the dump to the few real
|
|
// decks instead of ~100 empty slots.
|
|
var cardArr = ExtractLongArray(entry, "card_id_array");
|
|
if (cardArr == null || cardArr.Count == 0) continue;
|
|
|
|
var d = new Dictionary<string, object> { { "deck_format", fmt.Format } };
|
|
Copy(entry, "deck_no", d, "deck_no");
|
|
Copy(entry, "deck_name", d, "deck_name");
|
|
Copy(entry, "class_id", d, "class_id");
|
|
Copy(entry, "sleeve_id", d, "sleeve_id");
|
|
Copy(entry, "leader_skin_id", d, "leader_skin_id");
|
|
Copy(entry, "is_random_leader_skin", d, "is_random_leader_skin");
|
|
Copy(entry, "rotation_id", d, "my_rotation_id"); // UserDeck.rotation_id -> import my_rotation_id
|
|
d["card_id_array"] = cardArr;
|
|
decks.Add(d);
|
|
}
|
|
}
|
|
if (decks.Count > 0) dump["decks"] = decks;
|
|
}
|
|
|
|
private static List<long> ExtractLongArray(JsonData entry, string key)
|
|
{
|
|
var arr = SafeGet(entry, key);
|
|
if (arr == null || !arr.IsArray) return null;
|
|
var ids = new List<long>();
|
|
for (int i = 0; i < arr.Count; i++)
|
|
{
|
|
var v = arr[i];
|
|
if (v == null) continue;
|
|
if (v.IsInt) ids.Add((int)v);
|
|
else if (v.IsLong) ids.Add((long)v);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
// Splice the body into the envelope as nested JSON (parseable) or escaped string
|
|
// (fallback). Cannot route this through Dictionary<string,object> → JsonMapper.ToJson:
|
|
// a LitJson.JsonData value inside such a dict makes the reflection serializer
|
|
// mis-bracket and throw "Can't close an object here".
|
|
private static void AppendLineWithBody(string path, string envelopeJson, string body)
|
|
{
|
|
string bodyJson;
|
|
if (body == null)
|
|
{
|
|
bodyJson = "null";
|
|
}
|
|
else if (body.Length == 0)
|
|
{
|
|
bodyJson = "\"\"";
|
|
}
|
|
else
|
|
{
|
|
try { bodyJson = JsonMapper.ToObject(body).ToJson(); }
|
|
catch { bodyJson = JsonMapper.ToJson(body); }
|
|
}
|
|
string trimmed = envelopeJson.Substring(0, envelopeJson.Length - 1);
|
|
string line = trimmed + ",\"body\":" + bodyJson + "}";
|
|
lock (_lock)
|
|
{
|
|
File.AppendAllText(path, line + "\n");
|
|
}
|
|
}
|
|
}
|