Cleanup, addition of new captures, readme
This commit is contained in:
103
README.md
Normal file
103
README.md
Normal 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.
|
||||
289
SVSimLoader/CaptureWriter.cs
Normal file
289
SVSimLoader/CaptureWriter.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// <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")]
|
||||
[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<CardCSVData>))]
|
||||
[HarmonyPostfix]
|
||||
public static void ExamineCardMaster(List<CardCSVData> cardList)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
58
SVSimLoader/Patches/ExceptionLogging.cs
Normal file
58
SVSimLoader/Patches/ExceptionLogging.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
126
SVSimLoader/Patches/LeaderSkinPoolSweep.cs
Normal file
126
SVSimLoader/Patches/LeaderSkinPoolSweep.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
77
SVSimLoader/Patches/StorySectionProbe.cs
Normal file
77
SVSimLoader/Patches/StorySectionProbe.cs
Normal 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()}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
367
SVSimLoader/Patches/StorySweep.cs
Normal file
367
SVSimLoader/Patches/StorySweep.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -11,22 +11,61 @@ namespace SVSimLoader
|
||||
public class Plugin : BaseUnityPlugin
|
||||
{
|
||||
internal static ManualLogSource Log;
|
||||
public static Plugin Instance { get; private set; }
|
||||
private ConfigEntry<string> _applicationUrl;
|
||||
private ConfigEntry<bool> _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");
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<Reference Include="Assembly-CSharp">
|
||||
<HintPath>lib\Assembly-CSharp.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.CoreModule">
|
||||
<Reference Include="UnityEngine.CoreModule" Aliases="game">
|
||||
<HintPath>lib\UnityEngine.CoreModule.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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; }
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user