Cleanup, addition of new captures, readme

This commit is contained in:
gamer147
2026-05-25 21:52:20 -04:00
parent f22cc19bfc
commit 34a17a2b6b
10 changed files with 1236 additions and 13 deletions

103
README.md Normal file
View File

@@ -0,0 +1,103 @@
# SVSimLoader
BepInEx 5 / HarmonyX plugin injected into the Steam Shadowverse client. Two jobs:
1. **Redirect** the client to a local emulated server (or a capture proxy).
2. **Observe** the live client/server conversation and dump structured artifacts that feed `SVSim.Bootstrap` and the api-spec docs.
Built against the official Cygames build (decompiled source in `Shadowverse_Code_2026-05-23/`). Lives upstream of the rest of the SVSim project — everything else in the repo consumes what this plugin captures.
## Quick start
1. Install **BepInEx 5** (Mono build, matching the game's bitness) into the Shadowverse game directory. Launch the game once so BepInEx generates its folder tree, then close.
2. From this repo, build the plugin:
```sh
cd ClientLoader/SVSimLoader
dotnet build
```
3. Copy `SVSimLoader/bin/Debug/net46/SVSimLoader.dll` → `<game-dir>/BepInEx/plugins/`.
4. Launch the game once to generate `<game-dir>/BepInEx/config/SVSimLoader.cfg`, then close.
5. Edit the config. For local server work:
- `[Connection] ApplicationUrl = http://localhost:5148/`
- `[Connection] DisableEncryption = true`
For prod capture, leave `[Connection]` at defaults and toggle whichever `[Capture]` / `[Sweeps]` flags you need.
6. Launch the game. A capture session directory appears under `<game-dir>/BepInEx/svsim-captures/`.
## Build notes
Targets `net46` (Unity 5.6 / Mono). The csproj references `Assembly-CSharp.dll` and `UnityEngine.CoreModule.dll` out of `SVSimLoader/lib/` — populate from a working game install if those aren't already present. No `dotnet restore` quirks; a plain `dotnet build` is enough.
## Configuration
Settings live in `BepInEx/config/SVSimLoader.cfg`, generated on first launch. Three sections:
### `[Connection]` — where and how to talk to the server
| Key | Default | Purpose |
|---|---|---|
| `ApplicationUrl` | prod URL | Overrides `CustomPreference.GetApplicationServerURL`. Point at `http://localhost:5148/` for the local DCGEngine. |
| `DisableEncryption` | `false` | Forces the `encrypt` arg on `NetworkManager.Connect` to false. Local server understands plaintext; prod does not. |
### `[Capture]` — passive observe-and-record (safe to leave on)
| Key | Default | Output file |
|---|---|---|
| `EnableTrafficCapture` | `true` | `traffic.ndjson` — every HTTP request + response body |
| `EnableBattleCapture` | `true` | `battle-traffic.ndjson` — every Socket.IO battle frame |
| `DumpCardDB` | `false` | `cards.json` — full card master, one-shot on load |
| `DumpUserData` | `false` | `user-data.json` — viewer fields from `/load/index`, shaped for `/admin/import_viewer` |
### `[Sweeps]` — active prod-API traffic (deliberate opt-in, hits prod)
Probes (one extra request) and sweeps (many) fire automatically once the gating screen loads. All responses land in `traffic.ndjson` via the normal capture hook.
| Key | Default | Effect |
|---|---|---|
| `ProbeLimitedSection` | `false` | Fires `/limited_story/section` once on first `/mypage/refresh` |
| `ProbeEventSection` | `false` | Same for `/event_story/section`, 1s after the limited probe |
| `SweepLeaderSkinPools` | `false` | Walks every parent gacha id from `/pack/info`; ~18s for 35 packs |
| `SweepMainStory` | `false` | Walks every (section, chara, chapter); captures master `special_battle_setting` payloads. **Side effect**: unfinished chapters become `is_skipped=true` (blue "Cleared", no rewards). Use a throwaway account. |
| `SweepLimitedStory` | `false` | Same shape for limited-story sections |
| `SweepEventStory` | `false` | Same shape for event-story sections |
| `StorySweepPacingSeconds` | `5.0` | Seconds between requests (min 1s). 5s = ~6h for full main-story tree. |
| `StorySectionIdFilter` | `""` | Comma-separated section IDs to scope down. Empty = sweep all. Used to resume runs that hit `MAX_PASSES_PER_PAIR`. |
## Capture output
Each game launch creates a fresh session directory:
```
BepInEx/svsim-captures/<yyyy-MM-dd_HH-mm-ss>_<host>/
traffic.ndjson # HTTP req/resp (always, when EnableTrafficCapture)
battle-traffic.ndjson # Socket.IO frames
cards.json # one-shot card master dump
user-data.json # /admin/import_viewer-shaped viewer extract
special-battle-settings.ndjson # deduped sbs payloads from story sweeps
```
The `traffic_prod.ndjson` checked into `data_dumps/` is a curated paste of one such session, used as the seed source for `SVSim.Bootstrap/Data/prod-captures/`.
## Code layout
```
SVSimLoader/
Plugin.cs # BepInEx entry point, Config.Bind, Harmony.PatchAll
SvSimConfig.cs # plain static fields populated from Config.Bind
CaptureWriter.cs # session dir, ndjson writers, user-data extraction
Patches/
UrlPatches.cs # GetApplicationServerURL -> SvSimConfig.ApplicationUrl
DecryptPatch.cs # NetworkManager.Connect: optionally clear `encrypt` flag
ExaminationPatches.cs# NetworkTask.SetResponseData: traffic-capture fan-out hub
LeaderSkinPoolSweep.cs # /pack/info -> /pack/get_gacha_point_rewards sweep
StorySectionProbe.cs # /mypage/refresh -> /{limited,event}_story/section probes
StorySweep.cs # /*/section -> /{main,limited,event}_story/{start,finish} sweep
DummyLogging.cs # short-circuits LocalLog.MakeTreceLogToSend (no telemetry)
ExceptionLogging.cs # Unity exception/error -> BepInEx log, with last-response JSON
```
`ExaminationPatches.SetResponseData` is the central hub: every response goes through it, and it dispatches into the dump / probe / sweep modules based on URL + config. Adding a new passive extractor usually means adding one `if (...EndsWith("/some/url")) { ... }` block there plus a writer in `CaptureWriter`.
## Background
Live Cygames servers shut down end of June 2026 — this plugin's main reason for existing is to capture as much server-only data (special battle settings, gacha pool composition, reward tables) as possible before then. Spec fidelity downstream depends on the artifacts it dumps. See `docs/audits/` for the per-endpoint audit reports that drive what gets captured next.

View File

@@ -0,0 +1,289 @@
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 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");
}
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)
{
AppendNdjson(_trafficPath, new Dictionary<string, object>
{
{ "ts", DateTime.UtcNow.ToString("o") },
{ "direction", direction },
{ "url", url },
{ "encrypted", encrypted },
{ "body", body },
});
}
public static void AppendBattleTraffic(string direction, string uri, string body)
{
AppendNdjson(_battleTrafficPath, new Dictionary<string, object>
{
{ "ts", DateTime.UtcNow.ToString("o") },
{ "direction", direction },
{ "uri", uri },
{ "body", 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
{
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, "rupies", 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);
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 AppendNdjson(string path, Dictionary<string, object> entry)
{
string line = JsonMapper.ToJson(entry);
lock (_lock)
{
File.AppendAllText(path, line + "\n");
}
}
}

View File

@@ -1,9 +1,10 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using BestHTTP.SocketIO;
using System.Text;
using Cute; using Cute;
using HarmonyLib; using HarmonyLib;
using LitJson; using LitJson;
using MessagePack;
using Wizard; using Wizard;
namespace SVSimLoader.Patches; namespace SVSimLoader.Patches;
@@ -15,26 +16,178 @@ public static class ExaminationPatches
[HarmonyPrefix] [HarmonyPrefix]
public static bool SetResponseData(JsonData data, NetworkTask __instance) public static bool SetResponseData(JsonData data, NetworkTask __instance)
{ {
Plugin.Log.LogInfo($"Post to {__instance.Url} returned: {data.ToJson()}"); Plugin.Log.LogInfo($" {__instance.Url}");
if (SvSimConfig.EnableTrafficCapture)
{
CaptureWriter.AppendTraffic("response", __instance.Url, encrypted: false, body: data.ToJson());
}
if (SvSimConfig.DumpUserData && __instance.Url != null && __instance.Url.EndsWith("/load/index"))
{
// The /load/index response data is the inner `data` payload by this point — the
// outer `data_headers` wrapper has already been stripped by the network task base.
CaptureWriter.WriteUserDataFromLoadIndex(data);
}
if (SvSimConfig.SweepLeaderSkinPools && __instance.Url != null && __instance.Url.EndsWith("/pack/info"))
{
LeaderSkinPoolSweep.OnPackInfoResponse(data);
}
if (__instance.Url != null && IsStorySectionUrl(__instance.Url) &&
(SvSimConfig.SweepMainStory || SvSimConfig.SweepLimitedStory || SvSimConfig.SweepEventStory))
{
StorySweep.OnSectionResponse(__instance.Url);
}
if (__instance.Url != null && __instance.Url.EndsWith("/mypage/refresh") &&
(SvSimConfig.ProbeLimitedSection || SvSimConfig.ProbeEventSection))
{
StorySectionProbe.OnMypageRefreshResponse();
}
if (__instance.Url != null && IsStoryStartUrl(__instance.Url))
{
TryExtractSpecialBattleSettings(data);
}
return true; return true;
} }
private static bool IsStorySectionUrl(string url)
{
return url.EndsWith("/story/section")
|| url.EndsWith("/main_story/section")
|| url.EndsWith("/limited_story/section")
|| url.EndsWith("/event_story/section");
}
private static bool IsStoryStartUrl(string url)
{
return url.EndsWith("/main_story/start")
|| url.EndsWith("/limited_story/start")
|| url.EndsWith("/event_story/start");
}
/// <summary>
/// /{family}/start responses, after envelope-unwrap, look like:
/// { "0": <slot>|[], "1": <slot>|[], ..., "mission_parameter": [...] }
/// where each numeric key matches the index of the request's story_ids array. Each slot is either an
/// empty array (chapter has no sbs assigned) or an object containing a special_battle_setting payload.
/// Extract any sbs payloads and hand to CaptureWriter for dedup + append.
///
/// NOTE: SetResponseData's `data` param is the FULL envelope (including data_headers wrapper) — see
/// NetworkTask.cs:108-110 + getDataHeader at :513. Must descend into data["data"] first.
/// </summary>
private static void TryExtractSpecialBattleSettings(JsonData data)
{
try
{
if (data == null || !data.IsObject) return;
JsonData inner = data.Keys.Contains("data") ? data["data"] : data;
if (inner == null || !inner.IsObject) return;
foreach (var keyObj in inner.Keys)
{
string key = keyObj;
if (!int.TryParse(key, out _)) continue;
var slot = inner[key];
if (slot == null || !slot.IsObject || !slot.Keys.Contains("special_battle_setting")) continue;
var sbs = slot["special_battle_setting"];
if (sbs == null || !sbs.IsObject || !sbs.Keys.Contains("id")) continue;
var id = sbs["id"].ToString();
if (string.IsNullOrEmpty(id) || id == "0") continue;
CaptureWriter.AppendSpecialBattleSetting(id, sbs);
}
}
catch (Exception e)
{
Plugin.Log?.LogError($"TryExtractSpecialBattleSettings: {e}");
}
}
[HarmonyPatch(typeof(NetworkTask), "CreateBody")] [HarmonyPatch(typeof(NetworkTask), "CreateBody")]
[HarmonyPrefix] [HarmonyPrefix]
public static bool CreateBody(NetworkTask __instance, bool encrypt) public static bool CreateBody(NetworkTask __instance, bool encrypt)
{ {
Plugin.Log.LogInfo($"→ {__instance.Url} (encrypted={encrypt})");
string body = JsonMapper.ToJson(__instance.Params); string body = JsonMapper.ToJson(__instance.Params);
Plugin.Log.LogInfo($"Post to {__instance.Url} with encryption {encrypt} and with body: {body}"); if (SvSimConfig.EnableTrafficCapture)
{
CaptureWriter.AppendTraffic("request", __instance.Url, encrypted: encrypt, body: body);
}
// Track the most recent steam_id on any outgoing request — needed by the user-data
// dump (the /load/index response itself doesn't carry steam_id).
if (SvSimConfig.DumpUserData)
{
TryRecordSteamIdFromRequestBody(body);
}
return true; return true;
} }
private static void TryRecordSteamIdFromRequestBody(string body)
{
if (string.IsNullOrEmpty(body)) return;
try
{
JsonData parsed = JsonMapper.ToObject(body);
if (parsed == null || !parsed.IsObject || !parsed.Keys.Contains("steam_id")) return;
var val = parsed["steam_id"];
if (val.IsLong) CaptureWriter.RecordSteamId((ulong)(long)val);
else if (val.IsInt) CaptureWriter.RecordSteamId((ulong)(int)val);
}
catch
{
// Best-effort; some request bodies may not be JSON-shaped objects.
}
}
[HarmonyPatch(typeof(CardMaster), MethodType.Constructor, typeof(List<CardCSVData>))] [HarmonyPatch(typeof(CardMaster), MethodType.Constructor, typeof(List<CardCSVData>))]
[HarmonyPostfix] [HarmonyPostfix]
public static void ExamineCardMaster(List<CardCSVData> cardList) public static void ExamineCardMaster(List<CardCSVData> cardList)
{ {
if (SvSimConfig.DumpCardDB) if (SvSimConfig.DumpCardDB)
{ {
FileLog.Log(JsonMapper.ToJson(cardList)); CaptureWriter.WriteCards(JsonMapper.ToJson(cardList));
Plugin.Log.LogInfo($"Dumped {cardList.Count} cards to cards.json");
} }
} }
}
[HarmonyPatch(typeof(NetworkBattleSender), "EmitMsg")]
[HarmonyPrefix]
public static bool EmitBattleMsg(NetworkBattleDefine.NetworkBattleURI uri, Dictionary<string, object> dataList = null)
{
try
{
string uriString = $"{uri}";
Plugin.Log.LogInfo($"→ battle {uriString}");
if (SvSimConfig.EnableBattleCapture)
{
CaptureWriter.AppendBattleTraffic("send", uriString,
body: dataList == null ? null : JsonMapper.ToJson(dataList));
}
}
catch (Exception e)
{
Plugin.Log.LogError(e);
}
return true;
}
[HarmonyPatch(typeof(RealTimeNetworkAgent), "OnReceived")]
[HarmonyPrefix]
public static bool ReceiveBattleMsg(Packet packet)
{
try
{
byte[] bytes = packet.Attachments[0];
string src = MessagePackSerializer.Deserialize<string>(bytes);
string json = CryptAES.decryptForNode(src);
Plugin.Log.LogInfo("← battle");
if (SvSimConfig.EnableBattleCapture)
{
CaptureWriter.AppendBattleTraffic("receive", uri: null, body: json);
}
}
catch (Exception e)
{
Plugin.Log.LogError(e);
}
return true;
}
}

View File

@@ -0,0 +1,58 @@
extern alias game;
using System.Reflection;
using HarmonyLib;
using LitJson;
using Application = game::UnityEngine.Application;
using LogType = game::UnityEngine.LogType;
using StackTraceLogType = game::UnityEngine.StackTraceLogType;
namespace SVSimLoader.Patches;
[HarmonyPatch]
public class ExceptionLogging
{
private static string _lastResponseJson;
public static void Install()
{
Application.SetStackTraceLogType(LogType.Exception, StackTraceLogType.ScriptOnly);
Application.SetStackTraceLogType(LogType.Error, StackTraceLogType.ScriptOnly);
Application.logMessageReceived += OnLog;
}
private static void OnLog(string message, string stackTrace, LogType type)
{
if (type != LogType.Exception && type != LogType.Error) return;
if (message != null && message.StartsWith("[SVSim]")) return;
Plugin.Log.LogError($"[SVSim] Unity {type}: {message}\n{stackTrace}");
if (_lastResponseJson != null)
{
var snippet = _lastResponseJson.Length > 4000
? _lastResponseJson.Substring(0, 4000) + "…[truncated]"
: _lastResponseJson;
Plugin.Log.LogError($"[SVSim] Last parsed response JSON:\n{snippet}");
}
}
// JsonMapper.ToObject(string) is ambiguous with the generic ToObject<T>(string) overload
// when resolved through Harmony's attribute; pick the non-generic one explicitly.
private static MethodBase TargetMethod()
{
foreach (var m in typeof(JsonMapper).GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != nameof(JsonMapper.ToObject)) continue;
if (m.IsGenericMethod) continue;
var ps = m.GetParameters();
if (ps.Length == 1 && ps[0].ParameterType == typeof(string)) return m;
}
return null;
}
[HarmonyPostfix]
public static void CaptureJson(string json)
{
_lastResponseJson = json;
}
}

View File

@@ -0,0 +1,126 @@
using System.Collections;
using System.Collections.Generic;
using Cute;
using LitJson;
using UnityEngine;
using Wizard.Scripts.Network.Data.TaskData.SpotCardExchange;
namespace SVSimLoader.Patches;
/// <summary>
/// Fires GachaPointExchangeInfoTask (pack/get_gacha_point_rewards) for every
/// parent_gacha_id in /pack/info's pack_config_list, on the first /pack/info response
/// of the session. Responses are captured by the existing traffic hook in
/// ExaminationPatches.SetResponseData — no extra writer needed.
///
/// Originally targeted pack/get_leader_skin_owned_status (Plan B in the leader-cards
/// design doc), but that endpoint is server-gated on an active skin-select offering
/// and returned result_code=205 across all 35 packs in the May 2026 catalog. Switched
/// to pack/get_gacha_point_rewards which exposes the same card_id → leader_skin_id
/// mapping (via reward_type=10 entries) without the gate, and additionally yields
/// emblem/sleeve associations for every tradeable card.
///
/// Gated by SvSimConfig.SweepLeaderSkinPools (off by default).
/// Hits a prod API — keep pacing conservative.
/// </summary>
internal static class LeaderSkinPoolSweep
{
private static bool _sweepStarted;
private static readonly object _lock = new object();
public static void OnPackInfoResponse(JsonData data)
{
lock (_lock)
{
if (_sweepStarted) return;
_sweepStarted = true;
}
var ids = ExtractParentGachaIds(data);
if (ids.Count == 0)
{
Plugin.Log.LogWarning("LeaderSkinPoolSweep: /pack/info had no pack_config_list — nothing to sweep.");
return;
}
if (Plugin.Instance == null)
{
Plugin.Log.LogError("LeaderSkinPoolSweep: Plugin.Instance is null — cannot start coroutine.");
return;
}
Plugin.Log.LogInfo($"LeaderSkinPoolSweep: queued {ids.Count} packs for /pack/get_gacha_point_rewards sweep.");
Plugin.Instance.StartCoroutine(SweepCoroutine(ids));
}
private static List<int> ExtractParentGachaIds(JsonData data)
{
var ids = new List<int>();
if (data == null || !data.IsObject)
{
Plugin.Log.LogWarning("LeaderSkinPoolSweep: /pack/info response data was null or non-object.");
return ids;
}
// Defensive: the data parameter to SetResponseData might be either the inner `data`
// payload (matching the DumpUserData assumption) or the full envelope with data_headers
// outside. Different endpoints in this codebase seem to differ; check both shapes.
JsonData list = null;
if (data.Keys.Contains("pack_config_list"))
{
list = data["pack_config_list"];
}
else if (data.Keys.Contains("data") && data["data"] != null && data["data"].IsObject
&& data["data"].Keys.Contains("pack_config_list"))
{
list = data["data"]["pack_config_list"];
}
if (list == null)
{
var keys = string.Join(", ", data.Keys);
Plugin.Log.LogWarning($"LeaderSkinPoolSweep: pack_config_list not found at either data.pack_config_list or data.data.pack_config_list. Top-level keys observed: [{keys}]");
return ids;
}
if (!list.IsArray)
{
Plugin.Log.LogWarning($"LeaderSkinPoolSweep: pack_config_list was found but is not an array (type tag mismatch).");
return ids;
}
for (int i = 0; i < list.Count; i++)
{
var item = list[i];
if (item == null || !item.IsObject || !item.Keys.Contains("parent_gacha_id")) continue;
var v = item["parent_gacha_id"];
if (v.IsInt) ids.Add((int)v);
else if (v.IsLong) ids.Add((int)(long)v);
}
return ids;
}
private static IEnumerator SweepCoroutine(List<int> parentGachaIds)
{
int ok = 0, fail = 0;
for (int i = 0; i < parentGachaIds.Count; i++)
{
int id = parentGachaIds[i];
// odds_gacha_id and parent_gacha_id observed equal in the natural-flow capture
// (data_dumps/traffic_prod_tradeables_capture.ndjson). Pass the same value twice.
GachaPointExchangeInfoTask task = new GachaPointExchangeInfoTask();
task.SetParameter(id, id);
Plugin.Log.LogInfo($"LeaderSkinPoolSweep: [{i + 1}/{parentGachaIds.Count}] parent_gacha_id={id}");
yield return Toolbox.NetworkManager.Connect(task, _ => { });
if (task.isServerResultCodeOK())
{
ok++;
}
else
{
fail++;
Plugin.Log.LogWarning($"LeaderSkinPoolSweep: parent_gacha_id={id} returned result_code={task.GetResultCode()}");
}
// 0.5s per request is conservative pacing for a prod API. 35 packs ≈ 18s total.
yield return new WaitForSeconds(0.5f);
}
Plugin.Log.LogInfo($"LeaderSkinPoolSweep: complete. ok={ok} fail={fail}/{parentGachaIds.Count}");
}
}

View File

@@ -0,0 +1,77 @@
using System.Collections;
using Cute;
using UnityEngine;
using Wizard;
using Wizard.Story;
namespace SVSimLoader.Patches;
/// <summary>
/// On the first /mypage/refresh response of the session, fires /limited_story/section
/// and/or /event_story/section probes (per per-family config flags) to discover whether
/// limited or event content is currently scheduled / unlocked for this account.
///
/// Responses are captured by the existing ExaminationPatches.SetResponseData hook into
/// traffic.ndjson. If the matching SweepLimitedStory / SweepEventStory flag is ALSO
/// enabled, the probe's response will trigger that sweep via StorySweep.OnSectionResponse —
/// so probe + sweep combined is a single-config flow.
///
/// Triggered on /mypage/refresh (home-screen confirmation) rather than the section URLs
/// themselves to avoid stomping Data.StoryWorldDataManager mid-navigation while the user
/// is on a story screen. On the home page, no other code reads WorldDataManager.
/// </summary>
internal static class StorySectionProbe
{
private static bool _probed;
private static readonly object _lock = new object();
public static void OnMypageRefreshResponse()
{
bool runLimited, runEvent;
lock (_lock)
{
if (_probed) return;
runLimited = SvSimConfig.ProbeLimitedSection;
runEvent = SvSimConfig.ProbeEventSection;
if (!runLimited && !runEvent) return;
_probed = true;
}
if (Plugin.Instance == null)
{
Plugin.Log.LogError("StorySectionProbe: Plugin.Instance is null — cannot start coroutine.");
return;
}
Plugin.Log.LogInfo($"StorySectionProbe: triggered (limited={runLimited}, event={runEvent}).");
Plugin.Instance.StartCoroutine(ProbeCoroutine(runLimited, runEvent));
}
private static IEnumerator ProbeCoroutine(bool runLimited, bool runEvent)
{
if (runLimited)
{
yield return Probe(StoryEntranceType.LimitedStory, "limited");
yield return new WaitForSeconds(1f);
}
if (runEvent)
{
yield return Probe(StoryEntranceType.EventStory, "event");
}
}
private static IEnumerator Probe(StoryEntranceType entrance, string label)
{
var info = new SelectedStoryInfo(entrance);
var task = new StorySectionTask(info);
task.SetParameter(false);
yield return Toolbox.NetworkManager.Connect(task, _ => { });
if (task.isServerResultCodeOK())
{
Plugin.Log.LogInfo($"StorySectionProbe[{label}]: ok (response in traffic.ndjson).");
}
else
{
Plugin.Log.LogWarning($"StorySectionProbe[{label}]: rc={task.GetResultCode()}.");
}
}
}

View File

@@ -0,0 +1,367 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Cute;
using UnityEngine;
using Wizard;
using Wizard.Story;
namespace SVSimLoader.Patches;
/// <summary>
/// On the first /{story,main_story,limited_story,event_story}/section response of the
/// session (for whichever families are enabled in config), walks every (section, chara,
/// chapter) and emits /start + /finish (no-battle skip shape) per chapter. This captures
/// the master `special_battle_setting` payload — server-only data, unrecoverable post-shutdown
/// per project-story-capture-cheat memory — without needing recovery_data or any played battle.
///
/// Triggered automatically when the user lands on a story screen. Per-family one-shot per
/// session, gated by SvSimConfig.Sweep{Main,Limited,Event}Story flags (default OFF).
///
/// /start and /finish responses are captured automatically by the existing traffic hook in
/// ExaminationPatches.SetResponseData — no extra writer needed.
///
/// SIDE EFFECTS on the active account:
/// - Unfinished chapters become is_skipped=true is_finish=false (blue "Cleared" in UI,
/// no rewards granted). Already-cleared chapters are unchanged.
/// - Hits prod API at the configured pacing (default 5s/request).
/// - 2 calls per chapter; main_story alone is ~6h at 5s pacing for ~2400 chapters.
/// Use a throwaway / capture-purpose account.
///
/// Iteration order mimics natural play progression: sections sorted by AllStoryOrderId ASC
/// (oldest world first), charas in leader_list order (chara_id 1..8), chapters by
/// ChapterRowNum ASC with chapter_id as tiebreaker (handles "12a" / "12b" branching suffixes).
/// </summary>
internal static class StorySweep
{
private static bool _mainStarted;
private static bool _limitedStarted;
private static bool _eventStarted;
private static readonly object _lock = new object();
/// <summary>
/// Called from ExaminationPatches.SetResponseData when ANY of the four story-section URLs
/// lands. Schedules one coroutine per (enabled, not-yet-started) family.
/// </summary>
private struct Family
{
public StoryApiType ApiType;
public string Label;
}
public static void OnSectionResponse(string url)
{
var toRun = new List<Family>();
lock (_lock)
{
if (SvSimConfig.SweepMainStory && !_mainStarted)
{
_mainStarted = true;
toRun.Add(new Family { ApiType = StoryApiType.MainStory, Label = "main" });
}
if (SvSimConfig.SweepLimitedStory && !_limitedStarted)
{
_limitedStarted = true;
toRun.Add(new Family { ApiType = StoryApiType.LimitedStory, Label = "limited" });
}
if (SvSimConfig.SweepEventStory && !_eventStarted)
{
_eventStarted = true;
toRun.Add(new Family { ApiType = StoryApiType.EventStory, Label = "event" });
}
}
if (toRun.Count == 0) return;
if (Plugin.Instance == null)
{
Plugin.Log.LogError("StorySweep: Plugin.Instance is null — cannot start coroutine.");
return;
}
foreach (var entry in toRun)
{
Plugin.Log.LogInfo($"StorySweep[{entry.Label}]: triggered by {url}.");
Plugin.Instance.StartCoroutine(SweepCoroutine(entry.ApiType, entry.Label));
}
}
private static IEnumerator SweepCoroutine(StoryApiType apiType, string label)
{
// Defer one frame so the triggering task's Parse() has time to populate
// Data.StoryWorldDataManager (Parse runs after our SetResponseData prefix returns).
yield return null;
float pace = Mathf.Max(1f, SvSimConfig.StorySweepPacingSeconds);
var entrance = ApiToEntrance(apiType);
var allSections = Data.StoryWorldDataManager?.SectionDatas;
if (allSections == null || allSections.Count == 0)
{
Plugin.Log.LogWarning(
$"StorySweep[{label}]: Data.StoryWorldDataManager.SectionDatas was empty after Parse() — aborted.");
yield break;
}
// Walk in natural chronological order: oldest world/section first.
var sections = allSections
.Where(s => s != null && s.StoryApiType == apiType && !s.IsUnderMaintenance)
.OrderBy(s => s.AllStoryOrderId)
.ToList();
// Optional section-id filter — used to resume a sweep that capped on specific
// (section, chara) pairs without re-walking the whole story tree.
var filterRaw = SvSimConfig.StorySectionIdFilter;
if (!string.IsNullOrEmpty(filterRaw))
{
var allowedIds = new HashSet<int>();
foreach (var part in filterRaw.Split(','))
{
int id;
if (int.TryParse(part.Trim(), out id)) allowedIds.Add(id);
}
if (allowedIds.Count > 0)
{
var before = sections.Count;
sections = sections.Where(s => allowedIds.Contains(s.Id)).ToList();
Plugin.Log.LogInfo(
$"StorySweep[{label}]: StorySectionIdFilter='{filterRaw}' active — {sections.Count}/{before} sections retained.");
}
}
if (sections.Count == 0)
{
Plugin.Log.LogInfo(
$"StorySweep[{label}]: no sections matched StoryApiType.{apiType} in the loaded world data. " +
"Navigate to that story tab in-game (or trigger /{family}/section directly) to seed it.");
yield break;
}
Plugin.Log.LogInfo(
$"StorySweep[{label}]: {sections.Count} sections queued. Pacing={pace}s between requests.");
int chapters = 0, startOk = 0, startFail = 0, finishOk = 0, finishFail = 0;
int tutorialPlayOk = 0, tutorialPlayFail = 0;
for (int si = 0; si < sections.Count; si++)
{
var section = sections[si];
var info = new SelectedStoryInfo(entrance);
info.SetSection(section);
List<int?> charaIds;
var currentChapterByChara = new Dictionary<int, int>();
if (section.IsLeaderSelect)
{
var leaderTask = new StoryLeaderSelectTask(info);
yield return Toolbox.NetworkManager.Connect(leaderTask, _ => { });
yield return new WaitForSeconds(pace);
if (!leaderTask.isServerResultCodeOK())
{
Plugin.Log.LogWarning(
$"StorySweep[{label}]: section {section.Id} /leader_select rc={leaderTask.GetResultCode()}; skipping section.");
continue;
}
var leaderList = Data.StoryLeaderSelect?.DataList ?? new List<StoryLeaderSelectData>();
charaIds = leaderList.Select(d => (int?)d.CharaId).ToList();
foreach (var d in leaderList)
{
if (int.TryParse(d.CurrentChapter, out int cur))
{
currentChapterByChara[d.CharaId] = cur;
}
}
if (charaIds.Count == 0)
{
Plugin.Log.LogWarning(
$"StorySweep[{label}]: section {section.Id} leader_list was empty; skipping section.");
continue;
}
}
else
{
charaIds = new List<int?> { null };
}
for (int ci = 0; ci < charaIds.Count; ci++)
{
var charaId = charaIds[ci];
info.SetSectionChara(charaId);
// Multi-pass loop: server unlocks chapter N+1 only after chapter N is cleared,
// so /info's chapter list grows during the sweep. Re-fetch after each pass and
// process any newly-released chapters until /info stabilizes (or MAX_PASSES).
// Without this, only the first wave of is_released chapters per pair gets
// processed and ~14 follow-on chapters per chara go missed.
var processedStoryIds = new HashSet<int>();
// The processedStoryIds HashSet guarantees we never retry the same chapter,
// so the natural bound is the actual chapter count per (section, chara) —
// realistically <40 even for the longest late-game arcs. 50 is a generous
// safety net for a runaway loop while comfortably covering all real sections.
// (Earlier runs with cap=20 truncated sections 14/19/20 mid-expansion.)
const int MAX_PASSES_PER_PAIR = 50;
int passNum = 0;
bool pairAborted = false;
while (passNum < MAX_PASSES_PER_PAIR && !pairAborted)
{
passNum++;
var infoTask = new StoryInfoTask(info);
yield return Toolbox.NetworkManager.Connect(infoTask, _ => { });
yield return new WaitForSeconds(pace);
if (!infoTask.isServerResultCodeOK())
{
Plugin.Log.LogWarning(
$"StorySweep[{label}]: section {section.Id} chara={charaId?.ToString() ?? "(none)"} /info rc={infoTask.GetResultCode()}; skipping pair.");
break;
}
// Filter on IsReleased to skip chapters the server hasn't unlocked yet
// (/start on those returns rc=500). Include no-battle chapters
// (battle_exists=false — story epilogues like Bloodcraft ch.15):
// they need /finish to mark cleared. IsEnableBattleSkip is
// battle_exists && is_skip_enabled, so it excludes no-battle chapters —
// handle them via the !ExistsBattle branch.
// Exclude story_ids we've already processed this session so we don't
// loop forever if the server re-reports them as "released".
var chapterList = (Data.StoryInfo?.ChapterDataList ?? new List<StoryChapterData>())
.Where(c => c != null
&& c.IsReleased
&& !c.IsLocked
&& !c.IsMaintenanceChapter
&& (c.IsEnableBattleSkip || !c.ExistsBattle)
&& !processedStoryIds.Contains(c.StoryId))
.OrderBy(c => c.ChapterRowNum)
.ThenBy(c => c.ChapterId, System.StringComparer.Ordinal)
.ToList();
if (chapterList.Count == 0)
{
if (passNum > 1)
{
Plugin.Log.LogInfo(
$"StorySweep[{label}]: section={section.Id} chara={charaId?.ToString() ?? "(none)"} stabilized after {passNum - 1} pass(es), {processedStoryIds.Count} chapter(s) processed.");
}
break;
}
Plugin.Log.LogInfo(
$"StorySweep[{label}]: section={section.Id} ({si + 1}/{sections.Count}) " +
$"chara={charaId?.ToString() ?? "(none)"} ({ci + 1}/{charaIds.Count}) " +
$"pass {passNum} → {chapterList.Count} new chapter(s).");
// Circuit breaker: if /start fails N times in a row, the chara is gated
// by progression we can't bypass. Break out of this pair entirely.
const int CONSECUTIVE_START_FAIL_THRESHOLD = 10;
int consecutiveStartFail = 0;
for (int chi = 0; chi < chapterList.Count; chi++)
{
var chapter = chapterList[chi];
info.SetChapter(chapter, null);
chapters++;
// Mark processed up front so even on failure we don't retry
// the same chapter on subsequent passes within this pair.
processedStoryIds.Add(chapter.StoryId);
var startTask = new StoryStartTask(info);
yield return Toolbox.NetworkManager.Connect(startTask, _ => { });
yield return new WaitForSeconds(pace);
if (startTask.isServerResultCodeOK())
{
startOk++;
consecutiveStartFail = 0;
}
else
{
startFail++;
consecutiveStartFail++;
Plugin.Log.LogWarning(
$"StorySweep[{label}]: story_id={chapter.StoryId} /start rc={startTask.GetResultCode()} — skipping /finish.");
if (consecutiveStartFail >= CONSECUTIVE_START_FAIL_THRESHOLD)
{
Plugin.Log.LogWarning(
$"StorySweep[{label}]: {CONSECUTIVE_START_FAIL_THRESHOLD} consecutive /start failures in section={section.Id} chara={charaId?.ToString() ?? "(none)"} — aborting remaining {chapterList.Count - chi - 1} chapter(s) of this pair, moving on.");
pairAborted = true;
break;
}
continue;
}
// Tutorial-play unlock: the play-shape with null recovery_data is ONLY
// accepted by the server for actual class tutorials, which per the client's
// own definition (StoryChapterData.IsClassTutorial) means (section_id=1,
// chapter_id="1") and nothing else. For chapter 1 of any other section,
// the server returns rc=205 (observed 2026-05-25). Use the canonical
// IsClassTutorial flag rather than a heuristic.
bool isTutorialUnlock =
chapter.IsClassTutorial
&& charaId.HasValue
&& currentChapterByChara.TryGetValue(charaId.Value, out int cur)
&& cur <= 1
&& chapter.ClearStatus == StoryChapterData.ChapterClearStatus.NotCleared;
var finishTask = new StoryFinishTask(info);
if (isTutorialUnlock)
{
Plugin.Log.LogInfo(
$"StorySweep[{label}]: story_id={chapter.StoryId} chara={charaId} is class-tutorial — using play-shape /finish to unlock progression.");
finishTask.Params = new StoryFinishTask.StoryFinishTaskParam
{
story_id = chapter.StoryId,
is_finish = 1,
evolve_count = 1,
total_turn = 3,
deck_no = 0,
use_build_deck = 0,
deck_format = 1,
class_id = charaId.Value,
mission = new Dictionary<string, int>(),
recovery_data = null,
prosessing_time_data = null,
};
}
else
{
finishTask.SetParameterNoBattle(
isFinish: true,
isSelectAnotherEnding: false,
chosenUnlockChapterId: null);
}
yield return Toolbox.NetworkManager.Connect(finishTask, _ => { });
yield return new WaitForSeconds(pace);
if (finishTask.isServerResultCodeOK())
{
finishOk++;
if (isTutorialUnlock) tutorialPlayOk++;
}
else
{
finishFail++;
if (isTutorialUnlock) tutorialPlayFail++;
Plugin.Log.LogWarning(
$"StorySweep[{label}]: story_id={chapter.StoryId} /finish rc={finishTask.GetResultCode()}{(isTutorialUnlock ? " (tutorial-play attempt)" : "")}.");
}
}
}
if (passNum >= MAX_PASSES_PER_PAIR && !pairAborted)
{
Plugin.Log.LogWarning(
$"StorySweep[{label}]: section={section.Id} chara={charaId?.ToString() ?? "(none)"} hit MAX_PASSES_PER_PAIR={MAX_PASSES_PER_PAIR} — there may be more chapters unlockable on a future sweep run.");
}
}
}
Plugin.Log.LogInfo(
$"StorySweep[{label}]: complete. chapters={chapters} " +
$"start_ok={startOk}/{startOk + startFail} finish_ok={finishOk}/{finishOk + finishFail} " +
$"tutorial_play_ok={tutorialPlayOk}/{tutorialPlayOk + tutorialPlayFail}");
}
private static StoryEntranceType ApiToEntrance(StoryApiType apiType) => apiType switch
{
StoryApiType.MainStory => StoryEntranceType.MainStory,
StoryApiType.LimitedStory => StoryEntranceType.LimitedStory,
StoryApiType.EventStory => StoryEntranceType.EventStory,
_ => StoryEntranceType.None,
};
}

View File

@@ -11,22 +11,61 @@ namespace SVSimLoader
public class Plugin : BaseUnityPlugin public class Plugin : BaseUnityPlugin
{ {
internal static ManualLogSource Log; internal static ManualLogSource Log;
public static Plugin Instance { get; private set; }
private ConfigEntry<string> _applicationUrl; private ConfigEntry<string> _applicationUrl;
private ConfigEntry<bool> _disableEncryption; private ConfigEntry<bool> _disableEncryption;
private void Awake() private void Awake()
{ {
Instance = this;
Log = base.Logger; Log = base.Logger;
// Plugin startup logic // Plugin startup logic
Logger.LogInfo($"Plugin {PluginInfo.PLUGIN_GUID} is loaded!"); Logger.LogInfo($"Plugin {PluginInfo.PLUGIN_GUID} is loaded!");
_applicationUrl = Config.Bind("Urls", "ApplicationUrl", "https://utoongaize.shadowverse.jp/shadowverse/", _applicationUrl = Config.Bind("Connection", "ApplicationUrl", "https://utoongaize.shadowverse.jp/shadowverse/",
"The URL to the application server."); "The URL to the application server.");
_disableEncryption = Config.Bind("Settings", "DisableEncryption", false, _disableEncryption = Config.Bind("Connection", "DisableEncryption", false,
"Whether to disable encrypting HTTP requests"); "Whether to disable encrypting HTTP requests");
SvSimConfig.EnableTrafficCapture =
Config.Bind("Capture", "EnableTrafficCapture", true,
"Write each HTTP request/response body to traffic.ndjson in the current capture session directory").Value;
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.DumpCardDB = SvSimConfig.DumpCardDB =
Config.Bind("Settings", "DumpCardDB", false, "Dumps the loaded cards to a desktop log file").Value; Config.Bind("Capture", "DumpCardDB", false,
Logger.LogInfo($"Connecting to application server at {_applicationUrl.Value}"); "Dumps the loaded card master to cards.json in the current capture session directory").Value;
SvSimConfig.DumpUserData =
Config.Bind("Capture", "DumpUserData", false,
"On every /load/index response, extract essential viewer fields into user-data.json (suitable for POSTing to /admin/import_viewer on the local server)").Value;
SvSimConfig.ProbeLimitedSection =
Config.Bind("Sweeps", "ProbeLimitedSection", false,
"On the first /mypage/refresh response of the session, fire /limited_story/section to discover whether limited-story content is currently scheduled for this account. Response captured to traffic.ndjson. If SweepLimitedStory is also enabled, the probe's response triggers the sweep automatically.").Value;
SvSimConfig.ProbeEventSection =
Config.Bind("Sweeps", "ProbeEventSection", false,
"As ProbeLimitedSection but for /event_story/section. Fired with a 1s gap after the limited probe (if both are enabled).").Value;
SvSimConfig.SweepLeaderSkinPools =
Config.Bind("Sweeps", "SweepLeaderSkinPools", false,
"On the first /pack/info response of the session, fire /pack/get_gacha_point_rewards (GachaPointExchangeInfoTask) for every parent_gacha_id in pack_config_list. Responses are captured into traffic.ndjson via the existing traffic hook. Extracts card_id → leader_skin_id mappings via reward_type=10 entries (plus emblem/sleeve associations as a bonus). One-shot per session. WARNING: hits prod API — paced at 0.5s/request, ~18s total for 35 packs.").Value;
SvSimConfig.SweepMainStory =
Config.Bind("Sweeps", "SweepMainStory", false,
"On the first story-section response of the session, walk every (section, chara, chapter) in Data.StoryWorldDataManager matching StoryApiType.MainStory and emit /main_story/start + /main_story/finish (no-battle skip shape) per chapter with is_skip_enabled. Captures master `special_battle_setting` payloads (server-only data) into traffic.ndjson via the existing hook. Per-family one-shot per session. SIDE EFFECT: unfinished chapters become is_skipped=true is_finish=false (blue 'Cleared' in UI, no rewards). Use a throwaway account. WARNING: hits prod API — at 5s pacing, ~6h for the full main-story tree.").Value;
SvSimConfig.SweepLimitedStory =
Config.Bind("Sweeps", "SweepLimitedStory", false,
"As SweepMainStory but for StoryApiType.LimitedStory sections. Requires the loaded world data to include limited-story sections (navigate to the Limited Story tab in-game first if /story/section didn't surface them).").Value;
SvSimConfig.SweepEventStory =
Config.Bind("Sweeps", "SweepEventStory", false,
"As SweepMainStory but for StoryApiType.EventStory sections. Requires the loaded world data to include event-story sections (navigate to the Event Story tab in-game first if /story/section didn't surface them).").Value;
SvSimConfig.StorySweepPacingSeconds =
Config.Bind("Sweeps", "StorySweepPacingSeconds", 5.0f,
"Seconds to wait between consecutive requests during a story sweep. Clamped to a minimum of 1s. Default 5s is conservative for prod-API politeness. Lower values speed up the sweep but increase rate-limit/anti-cheat exposure.").Value;
SvSimConfig.StorySectionIdFilter =
Config.Bind("Sweeps", "StorySectionIdFilter", "",
"Optional comma-separated list of section IDs to restrict the story sweep to (e.g. '14,19,20'). Empty = sweep all sections. Useful for resuming a previous run that hit MAX_PASSES_PER_PAIR on specific sections without re-sweeping everything.").Value;
SvSimConfig.ApplicationUrl = _applicationUrl.Value; SvSimConfig.ApplicationUrl = _applicationUrl.Value;
SvSimConfig.DisableEncryption = _disableEncryption.Value; SvSimConfig.DisableEncryption = _disableEncryption.Value;
CaptureWriter.Initialize();
Logger.LogInfo($"Capture session directory: {CaptureWriter.SessionDirectory}");
Logger.LogInfo($"Connecting to application server at {_applicationUrl.Value}");
ExceptionLogging.Install();
var harmony = new Harmony(PluginInfo.PLUGIN_GUID); var harmony = new Harmony(PluginInfo.PLUGIN_GUID);
harmony.PatchAll(Assembly.GetExecutingAssembly()); harmony.PatchAll(Assembly.GetExecutingAssembly());
Logger.LogInfo("Patched"); Logger.LogInfo("Patched");

View File

@@ -24,7 +24,7 @@
<Reference Include="Assembly-CSharp"> <Reference Include="Assembly-CSharp">
<HintPath>lib\Assembly-CSharp.dll</HintPath> <HintPath>lib\Assembly-CSharp.dll</HintPath>
</Reference> </Reference>
<Reference Include="UnityEngine.CoreModule"> <Reference Include="UnityEngine.CoreModule" Aliases="game">
<HintPath>lib\UnityEngine.CoreModule.dll</HintPath> <HintPath>lib\UnityEngine.CoreModule.dll</HintPath>
</Reference> </Reference>
</ItemGroup> </ItemGroup>

View File

@@ -5,4 +5,15 @@ public static class SvSimConfig
public static string ApplicationUrl { get; set; } public static string ApplicationUrl { get; set; }
public static bool DisableEncryption { get; set; } public static bool DisableEncryption { get; set; }
public static bool DumpCardDB { get; set; } public static bool DumpCardDB { get; set; }
} public static bool EnableTrafficCapture { get; set; }
public static bool EnableBattleCapture { get; set; }
public static bool DumpUserData { get; set; }
public static bool SweepLeaderSkinPools { get; set; }
public static bool SweepMainStory { get; set; }
public static bool SweepLimitedStory { get; set; }
public static bool SweepEventStory { get; set; }
public static float StorySweepPacingSeconds { get; set; }
public static bool ProbeLimitedSection { get; set; }
public static bool ProbeEventSection { get; set; }
public static string StorySectionIdFilter { get; set; }
}