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.Collections.Generic;
|
||||||
using System.Linq;
|
using BestHTTP.SocketIO;
|
||||||
using System.Text;
|
|
||||||
using Cute;
|
using Cute;
|
||||||
using HarmonyLib;
|
using HarmonyLib;
|
||||||
using LitJson;
|
using LitJson;
|
||||||
|
using MessagePack;
|
||||||
using Wizard;
|
using Wizard;
|
||||||
|
|
||||||
namespace SVSimLoader.Patches;
|
namespace SVSimLoader.Patches;
|
||||||
@@ -15,26 +16,178 @@ public static class ExaminationPatches
|
|||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
public static bool SetResponseData(JsonData data, NetworkTask __instance)
|
public static bool SetResponseData(JsonData data, NetworkTask __instance)
|
||||||
{
|
{
|
||||||
Plugin.Log.LogInfo($"Post to {__instance.Url} returned: {data.ToJson()}");
|
Plugin.Log.LogInfo($"← {__instance.Url}");
|
||||||
|
if (SvSimConfig.EnableTrafficCapture)
|
||||||
|
{
|
||||||
|
CaptureWriter.AppendTraffic("response", __instance.Url, encrypted: false, body: data.ToJson());
|
||||||
|
}
|
||||||
|
if (SvSimConfig.DumpUserData && __instance.Url != null && __instance.Url.EndsWith("/load/index"))
|
||||||
|
{
|
||||||
|
// The /load/index response data is the inner `data` payload by this point — the
|
||||||
|
// outer `data_headers` wrapper has already been stripped by the network task base.
|
||||||
|
CaptureWriter.WriteUserDataFromLoadIndex(data);
|
||||||
|
}
|
||||||
|
if (SvSimConfig.SweepLeaderSkinPools && __instance.Url != null && __instance.Url.EndsWith("/pack/info"))
|
||||||
|
{
|
||||||
|
LeaderSkinPoolSweep.OnPackInfoResponse(data);
|
||||||
|
}
|
||||||
|
if (__instance.Url != null && IsStorySectionUrl(__instance.Url) &&
|
||||||
|
(SvSimConfig.SweepMainStory || SvSimConfig.SweepLimitedStory || SvSimConfig.SweepEventStory))
|
||||||
|
{
|
||||||
|
StorySweep.OnSectionResponse(__instance.Url);
|
||||||
|
}
|
||||||
|
if (__instance.Url != null && __instance.Url.EndsWith("/mypage/refresh") &&
|
||||||
|
(SvSimConfig.ProbeLimitedSection || SvSimConfig.ProbeEventSection))
|
||||||
|
{
|
||||||
|
StorySectionProbe.OnMypageRefreshResponse();
|
||||||
|
}
|
||||||
|
if (__instance.Url != null && IsStoryStartUrl(__instance.Url))
|
||||||
|
{
|
||||||
|
TryExtractSpecialBattleSettings(data);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsStorySectionUrl(string url)
|
||||||
|
{
|
||||||
|
return url.EndsWith("/story/section")
|
||||||
|
|| url.EndsWith("/main_story/section")
|
||||||
|
|| url.EndsWith("/limited_story/section")
|
||||||
|
|| url.EndsWith("/event_story/section");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsStoryStartUrl(string url)
|
||||||
|
{
|
||||||
|
return url.EndsWith("/main_story/start")
|
||||||
|
|| url.EndsWith("/limited_story/start")
|
||||||
|
|| url.EndsWith("/event_story/start");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// /{family}/start responses, after envelope-unwrap, look like:
|
||||||
|
/// { "0": <slot>|[], "1": <slot>|[], ..., "mission_parameter": [...] }
|
||||||
|
/// where each numeric key matches the index of the request's story_ids array. Each slot is either an
|
||||||
|
/// empty array (chapter has no sbs assigned) or an object containing a special_battle_setting payload.
|
||||||
|
/// Extract any sbs payloads and hand to CaptureWriter for dedup + append.
|
||||||
|
///
|
||||||
|
/// NOTE: SetResponseData's `data` param is the FULL envelope (including data_headers wrapper) — see
|
||||||
|
/// NetworkTask.cs:108-110 + getDataHeader at :513. Must descend into data["data"] first.
|
||||||
|
/// </summary>
|
||||||
|
private static void TryExtractSpecialBattleSettings(JsonData data)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (data == null || !data.IsObject) return;
|
||||||
|
JsonData inner = data.Keys.Contains("data") ? data["data"] : data;
|
||||||
|
if (inner == null || !inner.IsObject) return;
|
||||||
|
foreach (var keyObj in inner.Keys)
|
||||||
|
{
|
||||||
|
string key = keyObj;
|
||||||
|
if (!int.TryParse(key, out _)) continue;
|
||||||
|
var slot = inner[key];
|
||||||
|
if (slot == null || !slot.IsObject || !slot.Keys.Contains("special_battle_setting")) continue;
|
||||||
|
var sbs = slot["special_battle_setting"];
|
||||||
|
if (sbs == null || !sbs.IsObject || !sbs.Keys.Contains("id")) continue;
|
||||||
|
var id = sbs["id"].ToString();
|
||||||
|
if (string.IsNullOrEmpty(id) || id == "0") continue;
|
||||||
|
CaptureWriter.AppendSpecialBattleSetting(id, sbs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Plugin.Log?.LogError($"TryExtractSpecialBattleSettings: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HarmonyPatch(typeof(NetworkTask), "CreateBody")]
|
[HarmonyPatch(typeof(NetworkTask), "CreateBody")]
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
public static bool CreateBody(NetworkTask __instance, bool encrypt)
|
public static bool CreateBody(NetworkTask __instance, bool encrypt)
|
||||||
{
|
{
|
||||||
|
Plugin.Log.LogInfo($"→ {__instance.Url} (encrypted={encrypt})");
|
||||||
string body = JsonMapper.ToJson(__instance.Params);
|
string body = JsonMapper.ToJson(__instance.Params);
|
||||||
Plugin.Log.LogInfo($"Post to {__instance.Url} with encryption {encrypt} and with body: {body}");
|
if (SvSimConfig.EnableTrafficCapture)
|
||||||
|
{
|
||||||
|
CaptureWriter.AppendTraffic("request", __instance.Url, encrypted: encrypt, body: body);
|
||||||
|
}
|
||||||
|
// Track the most recent steam_id on any outgoing request — needed by the user-data
|
||||||
|
// dump (the /load/index response itself doesn't carry steam_id).
|
||||||
|
if (SvSimConfig.DumpUserData)
|
||||||
|
{
|
||||||
|
TryRecordSteamIdFromRequestBody(body);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void TryRecordSteamIdFromRequestBody(string body)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(body)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JsonData parsed = JsonMapper.ToObject(body);
|
||||||
|
if (parsed == null || !parsed.IsObject || !parsed.Keys.Contains("steam_id")) return;
|
||||||
|
var val = parsed["steam_id"];
|
||||||
|
if (val.IsLong) CaptureWriter.RecordSteamId((ulong)(long)val);
|
||||||
|
else if (val.IsInt) CaptureWriter.RecordSteamId((ulong)(int)val);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort; some request bodies may not be JSON-shaped objects.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HarmonyPatch(typeof(CardMaster), MethodType.Constructor, typeof(List<CardCSVData>))]
|
[HarmonyPatch(typeof(CardMaster), MethodType.Constructor, typeof(List<CardCSVData>))]
|
||||||
[HarmonyPostfix]
|
[HarmonyPostfix]
|
||||||
public static void ExamineCardMaster(List<CardCSVData> cardList)
|
public static void ExamineCardMaster(List<CardCSVData> cardList)
|
||||||
{
|
{
|
||||||
if (SvSimConfig.DumpCardDB)
|
if (SvSimConfig.DumpCardDB)
|
||||||
{
|
{
|
||||||
FileLog.Log(JsonMapper.ToJson(cardList));
|
CaptureWriter.WriteCards(JsonMapper.ToJson(cardList));
|
||||||
|
Plugin.Log.LogInfo($"Dumped {cardList.Count} cards to cards.json");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
[HarmonyPatch(typeof(NetworkBattleSender), "EmitMsg")]
|
||||||
|
[HarmonyPrefix]
|
||||||
|
public static bool EmitBattleMsg(NetworkBattleDefine.NetworkBattleURI uri, Dictionary<string, object> dataList = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string uriString = $"{uri}";
|
||||||
|
Plugin.Log.LogInfo($"→ battle {uriString}");
|
||||||
|
if (SvSimConfig.EnableBattleCapture)
|
||||||
|
{
|
||||||
|
CaptureWriter.AppendBattleTraffic("send", uriString,
|
||||||
|
body: dataList == null ? null : JsonMapper.ToJson(dataList));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Plugin.Log.LogError(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HarmonyPatch(typeof(RealTimeNetworkAgent), "OnReceived")]
|
||||||
|
[HarmonyPrefix]
|
||||||
|
public static bool ReceiveBattleMsg(Packet packet)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] bytes = packet.Attachments[0];
|
||||||
|
string src = MessagePackSerializer.Deserialize<string>(bytes);
|
||||||
|
string json = CryptAES.decryptForNode(src);
|
||||||
|
Plugin.Log.LogInfo("← battle");
|
||||||
|
if (SvSimConfig.EnableBattleCapture)
|
||||||
|
{
|
||||||
|
CaptureWriter.AppendBattleTraffic("receive", uri: null, body: json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Plugin.Log.LogError(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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
|
public class Plugin : BaseUnityPlugin
|
||||||
{
|
{
|
||||||
internal static ManualLogSource Log;
|
internal static ManualLogSource Log;
|
||||||
|
public static Plugin Instance { get; private set; }
|
||||||
private ConfigEntry<string> _applicationUrl;
|
private ConfigEntry<string> _applicationUrl;
|
||||||
private ConfigEntry<bool> _disableEncryption;
|
private ConfigEntry<bool> _disableEncryption;
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
|
Instance = this;
|
||||||
Log = base.Logger;
|
Log = base.Logger;
|
||||||
// Plugin startup logic
|
// Plugin startup logic
|
||||||
Logger.LogInfo($"Plugin {PluginInfo.PLUGIN_GUID} is loaded!");
|
Logger.LogInfo($"Plugin {PluginInfo.PLUGIN_GUID} is loaded!");
|
||||||
_applicationUrl = Config.Bind("Urls", "ApplicationUrl", "https://utoongaize.shadowverse.jp/shadowverse/",
|
_applicationUrl = Config.Bind("Connection", "ApplicationUrl", "https://utoongaize.shadowverse.jp/shadowverse/",
|
||||||
"The URL to the application server.");
|
"The URL to the application server.");
|
||||||
_disableEncryption = Config.Bind("Settings", "DisableEncryption", false,
|
_disableEncryption = Config.Bind("Connection", "DisableEncryption", false,
|
||||||
"Whether to disable encrypting HTTP requests");
|
"Whether to disable encrypting HTTP requests");
|
||||||
|
SvSimConfig.EnableTrafficCapture =
|
||||||
|
Config.Bind("Capture", "EnableTrafficCapture", true,
|
||||||
|
"Write each HTTP request/response body to traffic.ndjson in the current capture session directory").Value;
|
||||||
|
SvSimConfig.EnableBattleCapture =
|
||||||
|
Config.Bind("Capture", "EnableBattleCapture", true,
|
||||||
|
"Write each Socket.IO battle send/receive body to battle-traffic.ndjson in the current capture session directory").Value;
|
||||||
SvSimConfig.DumpCardDB =
|
SvSimConfig.DumpCardDB =
|
||||||
Config.Bind("Settings", "DumpCardDB", false, "Dumps the loaded cards to a desktop log file").Value;
|
Config.Bind("Capture", "DumpCardDB", false,
|
||||||
Logger.LogInfo($"Connecting to application server at {_applicationUrl.Value}");
|
"Dumps the loaded card master to cards.json in the current capture session directory").Value;
|
||||||
|
SvSimConfig.DumpUserData =
|
||||||
|
Config.Bind("Capture", "DumpUserData", false,
|
||||||
|
"On every /load/index response, extract essential viewer fields into user-data.json (suitable for POSTing to /admin/import_viewer on the local server)").Value;
|
||||||
|
SvSimConfig.ProbeLimitedSection =
|
||||||
|
Config.Bind("Sweeps", "ProbeLimitedSection", false,
|
||||||
|
"On the first /mypage/refresh response of the session, fire /limited_story/section to discover whether limited-story content is currently scheduled for this account. Response captured to traffic.ndjson. If SweepLimitedStory is also enabled, the probe's response triggers the sweep automatically.").Value;
|
||||||
|
SvSimConfig.ProbeEventSection =
|
||||||
|
Config.Bind("Sweeps", "ProbeEventSection", false,
|
||||||
|
"As ProbeLimitedSection but for /event_story/section. Fired with a 1s gap after the limited probe (if both are enabled).").Value;
|
||||||
|
SvSimConfig.SweepLeaderSkinPools =
|
||||||
|
Config.Bind("Sweeps", "SweepLeaderSkinPools", false,
|
||||||
|
"On the first /pack/info response of the session, fire /pack/get_gacha_point_rewards (GachaPointExchangeInfoTask) for every parent_gacha_id in pack_config_list. Responses are captured into traffic.ndjson via the existing traffic hook. Extracts card_id → leader_skin_id mappings via reward_type=10 entries (plus emblem/sleeve associations as a bonus). One-shot per session. WARNING: hits prod API — paced at 0.5s/request, ~18s total for 35 packs.").Value;
|
||||||
|
SvSimConfig.SweepMainStory =
|
||||||
|
Config.Bind("Sweeps", "SweepMainStory", false,
|
||||||
|
"On the first story-section response of the session, walk every (section, chara, chapter) in Data.StoryWorldDataManager matching StoryApiType.MainStory and emit /main_story/start + /main_story/finish (no-battle skip shape) per chapter with is_skip_enabled. Captures master `special_battle_setting` payloads (server-only data) into traffic.ndjson via the existing hook. Per-family one-shot per session. SIDE EFFECT: unfinished chapters become is_skipped=true is_finish=false (blue 'Cleared' in UI, no rewards). Use a throwaway account. WARNING: hits prod API — at 5s pacing, ~6h for the full main-story tree.").Value;
|
||||||
|
SvSimConfig.SweepLimitedStory =
|
||||||
|
Config.Bind("Sweeps", "SweepLimitedStory", false,
|
||||||
|
"As SweepMainStory but for StoryApiType.LimitedStory sections. Requires the loaded world data to include limited-story sections (navigate to the Limited Story tab in-game first if /story/section didn't surface them).").Value;
|
||||||
|
SvSimConfig.SweepEventStory =
|
||||||
|
Config.Bind("Sweeps", "SweepEventStory", false,
|
||||||
|
"As SweepMainStory but for StoryApiType.EventStory sections. Requires the loaded world data to include event-story sections (navigate to the Event Story tab in-game first if /story/section didn't surface them).").Value;
|
||||||
|
SvSimConfig.StorySweepPacingSeconds =
|
||||||
|
Config.Bind("Sweeps", "StorySweepPacingSeconds", 5.0f,
|
||||||
|
"Seconds to wait between consecutive requests during a story sweep. Clamped to a minimum of 1s. Default 5s is conservative for prod-API politeness. Lower values speed up the sweep but increase rate-limit/anti-cheat exposure.").Value;
|
||||||
|
SvSimConfig.StorySectionIdFilter =
|
||||||
|
Config.Bind("Sweeps", "StorySectionIdFilter", "",
|
||||||
|
"Optional comma-separated list of section IDs to restrict the story sweep to (e.g. '14,19,20'). Empty = sweep all sections. Useful for resuming a previous run that hit MAX_PASSES_PER_PAIR on specific sections without re-sweeping everything.").Value;
|
||||||
SvSimConfig.ApplicationUrl = _applicationUrl.Value;
|
SvSimConfig.ApplicationUrl = _applicationUrl.Value;
|
||||||
SvSimConfig.DisableEncryption = _disableEncryption.Value;
|
SvSimConfig.DisableEncryption = _disableEncryption.Value;
|
||||||
|
CaptureWriter.Initialize();
|
||||||
|
Logger.LogInfo($"Capture session directory: {CaptureWriter.SessionDirectory}");
|
||||||
|
Logger.LogInfo($"Connecting to application server at {_applicationUrl.Value}");
|
||||||
|
ExceptionLogging.Install();
|
||||||
var harmony = new Harmony(PluginInfo.PLUGIN_GUID);
|
var harmony = new Harmony(PluginInfo.PLUGIN_GUID);
|
||||||
harmony.PatchAll(Assembly.GetExecutingAssembly());
|
harmony.PatchAll(Assembly.GetExecutingAssembly());
|
||||||
Logger.LogInfo("Patched");
|
Logger.LogInfo("Patched");
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<Reference Include="Assembly-CSharp">
|
<Reference Include="Assembly-CSharp">
|
||||||
<HintPath>lib\Assembly-CSharp.dll</HintPath>
|
<HintPath>lib\Assembly-CSharp.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="UnityEngine.CoreModule">
|
<Reference Include="UnityEngine.CoreModule" Aliases="game">
|
||||||
<HintPath>lib\UnityEngine.CoreModule.dll</HintPath>
|
<HintPath>lib\UnityEngine.CoreModule.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -5,4 +5,15 @@ public static class SvSimConfig
|
|||||||
public static string ApplicationUrl { get; set; }
|
public static string ApplicationUrl { get; set; }
|
||||||
public static bool DisableEncryption { get; set; }
|
public static bool DisableEncryption { get; set; }
|
||||||
public static bool DumpCardDB { get; set; }
|
public static bool DumpCardDB { get; set; }
|
||||||
}
|
public static bool EnableTrafficCapture { get; set; }
|
||||||
|
public static bool EnableBattleCapture { get; set; }
|
||||||
|
public static bool DumpUserData { get; set; }
|
||||||
|
public static bool SweepLeaderSkinPools { get; set; }
|
||||||
|
public static bool SweepMainStory { get; set; }
|
||||||
|
public static bool SweepLimitedStory { get; set; }
|
||||||
|
public static bool SweepEventStory { get; set; }
|
||||||
|
public static float StorySweepPacingSeconds { get; set; }
|
||||||
|
public static bool ProbeLimitedSection { get; set; }
|
||||||
|
public static bool ProbeEventSection { get; set; }
|
||||||
|
public static string StorySectionIdFilter { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user