From 34a17a2b6b65a892c6acea8cfc1e1c081e28c9c5 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 25 May 2026 21:52:20 -0400 Subject: [PATCH] Cleanup, addition of new captures, readme --- README.md | 103 ++++++ SVSimLoader/CaptureWriter.cs | 289 ++++++++++++++++ SVSimLoader/Patches/ExaminationPatches.cs | 167 +++++++++- SVSimLoader/Patches/ExceptionLogging.cs | 58 ++++ SVSimLoader/Patches/LeaderSkinPoolSweep.cs | 126 +++++++ SVSimLoader/Patches/StorySectionProbe.cs | 77 +++++ SVSimLoader/Patches/StorySweep.cs | 367 +++++++++++++++++++++ SVSimLoader/Plugin.cs | 47 ++- SVSimLoader/SVSimLoader.csproj | 2 +- SVSimLoader/SvSimConfig.cs | 13 +- 10 files changed, 1236 insertions(+), 13 deletions(-) create mode 100644 README.md create mode 100644 SVSimLoader/CaptureWriter.cs create mode 100644 SVSimLoader/Patches/ExceptionLogging.cs create mode 100644 SVSimLoader/Patches/LeaderSkinPoolSweep.cs create mode 100644 SVSimLoader/Patches/StorySectionProbe.cs create mode 100644 SVSimLoader/Patches/StorySweep.cs diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c0934f --- /dev/null +++ b/README.md @@ -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` → `/BepInEx/plugins/`. +4. Launch the game once to generate `/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 `/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/_/ + 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. diff --git a/SVSimLoader/CaptureWriter.cs b/SVSimLoader/CaptureWriter.cs new file mode 100644 index 0000000..bcc552f --- /dev/null +++ b/SVSimLoader/CaptureWriter.cs @@ -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 _seenSbsIds = new HashSet(); + 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); + } + } + + /// + /// 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. + /// + 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 + { + { "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 + { + { "ts", DateTime.UtcNow.ToString("o") }, + { "direction", direction }, + { "uri", uri }, + { "body", body }, + }); + } + + /// + /// 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). + /// + public static void RecordSteamId(ulong steamId) + { + if (steamId != 0) _lastSeenSteamId = steamId; + } + + /// + /// 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). + /// + 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 + { + { "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(); + 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 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 dump, string destKey) + { + var list = SafeGet(data, listKey); + if (list == null || !list.IsArray) return; + var ids = new List(); + 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 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(); + 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 dump) + { + var list = SafeGet(data, "user_leader_skin_list"); + if (list == null || !list.IsArray) return; + var ids = new List(); + 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 dump) + { + var list = SafeGet(data, "user_class_list"); + if (list == null || !list.IsArray) return; + var classes = new List>(); + 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(); + 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 entry) + { + string line = JsonMapper.ToJson(entry); + lock (_lock) + { + File.AppendAllText(path, line + "\n"); + } + } +} diff --git a/SVSimLoader/Patches/ExaminationPatches.cs b/SVSimLoader/Patches/ExaminationPatches.cs index 6a0bee4..13ce38a 100644 --- a/SVSimLoader/Patches/ExaminationPatches.cs +++ b/SVSimLoader/Patches/ExaminationPatches.cs @@ -1,9 +1,10 @@ +using System; using System.Collections.Generic; -using System.Linq; -using System.Text; +using BestHTTP.SocketIO; using Cute; using HarmonyLib; using LitJson; +using MessagePack; using Wizard; namespace SVSimLoader.Patches; @@ -15,26 +16,178 @@ public static class ExaminationPatches [HarmonyPrefix] 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; } - + + 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"); + } + + /// + /// /{family}/start responses, after envelope-unwrap, look like: + /// { "0": |[], "1": |[], ..., "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. + /// + 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")] [HarmonyPrefix] public static bool CreateBody(NetworkTask __instance, bool encrypt) { + Plugin.Log.LogInfo($"→ {__instance.Url} (encrypted={encrypt})"); 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; } + 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))] [HarmonyPostfix] public static void ExamineCardMaster(List cardList) { if (SvSimConfig.DumpCardDB) { - FileLog.Log(JsonMapper.ToJson(cardList)); + CaptureWriter.WriteCards(JsonMapper.ToJson(cardList)); + Plugin.Log.LogInfo($"Dumped {cardList.Count} cards to cards.json"); } } -} \ No newline at end of file + + [HarmonyPatch(typeof(NetworkBattleSender), "EmitMsg")] + [HarmonyPrefix] + public static bool EmitBattleMsg(NetworkBattleDefine.NetworkBattleURI uri, Dictionary 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(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; + } +} diff --git a/SVSimLoader/Patches/ExceptionLogging.cs b/SVSimLoader/Patches/ExceptionLogging.cs new file mode 100644 index 0000000..f401feb --- /dev/null +++ b/SVSimLoader/Patches/ExceptionLogging.cs @@ -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(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; + } +} diff --git a/SVSimLoader/Patches/LeaderSkinPoolSweep.cs b/SVSimLoader/Patches/LeaderSkinPoolSweep.cs new file mode 100644 index 0000000..55ae4cc --- /dev/null +++ b/SVSimLoader/Patches/LeaderSkinPoolSweep.cs @@ -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; + +/// +/// 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. +/// +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 ExtractParentGachaIds(JsonData data) + { + var ids = new List(); + 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 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}"); + } +} diff --git a/SVSimLoader/Patches/StorySectionProbe.cs b/SVSimLoader/Patches/StorySectionProbe.cs new file mode 100644 index 0000000..fc7e5c4 --- /dev/null +++ b/SVSimLoader/Patches/StorySectionProbe.cs @@ -0,0 +1,77 @@ +using System.Collections; +using Cute; +using UnityEngine; +using Wizard; +using Wizard.Story; + +namespace SVSimLoader.Patches; + +/// +/// 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. +/// +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()}."); + } + } +} diff --git a/SVSimLoader/Patches/StorySweep.cs b/SVSimLoader/Patches/StorySweep.cs new file mode 100644 index 0000000..94f82f1 --- /dev/null +++ b/SVSimLoader/Patches/StorySweep.cs @@ -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; + +/// +/// 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). +/// +internal static class StorySweep +{ + private static bool _mainStarted; + private static bool _limitedStarted; + private static bool _eventStarted; + private static readonly object _lock = new object(); + + /// + /// Called from ExaminationPatches.SetResponseData when ANY of the four story-section URLs + /// lands. Schedules one coroutine per (enabled, not-yet-started) family. + /// + private struct Family + { + public StoryApiType ApiType; + public string Label; + } + + public static void OnSectionResponse(string url) + { + var toRun = new List(); + 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(); + 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 charaIds; + var currentChapterByChara = new Dictionary(); + 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(); + 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 { 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(); + // 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()) + .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(), + 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, + }; +} diff --git a/SVSimLoader/Plugin.cs b/SVSimLoader/Plugin.cs index 9b888a4..7b137b2 100644 --- a/SVSimLoader/Plugin.cs +++ b/SVSimLoader/Plugin.cs @@ -11,22 +11,61 @@ namespace SVSimLoader public class Plugin : BaseUnityPlugin { internal static ManualLogSource Log; + public static Plugin Instance { get; private set; } private ConfigEntry _applicationUrl; private ConfigEntry _disableEncryption; private void Awake() { + Instance = this; Log = base.Logger; // Plugin startup logic 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."); - _disableEncryption = Config.Bind("Settings", "DisableEncryption", false, + _disableEncryption = Config.Bind("Connection", "DisableEncryption", false, "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 = - Config.Bind("Settings", "DumpCardDB", false, "Dumps the loaded cards to a desktop log file").Value; - Logger.LogInfo($"Connecting to application server at {_applicationUrl.Value}"); + Config.Bind("Capture", "DumpCardDB", false, + "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.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); harmony.PatchAll(Assembly.GetExecutingAssembly()); Logger.LogInfo("Patched"); diff --git a/SVSimLoader/SVSimLoader.csproj b/SVSimLoader/SVSimLoader.csproj index a21b1e3..ef9e61c 100644 --- a/SVSimLoader/SVSimLoader.csproj +++ b/SVSimLoader/SVSimLoader.csproj @@ -24,7 +24,7 @@ lib\Assembly-CSharp.dll - + lib\UnityEngine.CoreModule.dll diff --git a/SVSimLoader/SvSimConfig.cs b/SVSimLoader/SvSimConfig.cs index a8a2e91..9da9074 100644 --- a/SVSimLoader/SvSimConfig.cs +++ b/SVSimLoader/SvSimConfig.cs @@ -5,4 +5,15 @@ public static class SvSimConfig public static string ApplicationUrl { get; set; } public static bool DisableEncryption { get; set; } public static bool DumpCardDB { get; set; } -} \ No newline at end of file + 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; } +}