Files
SVSimLoader/SVSimLoader/CaptureWriter.cs
gamer147 b3df361f6d 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>
2026-06-04 13:24:24 -04:00

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");
}
}
}