Compare commits

..

8 Commits

Author SHA1 Message Date
gamer147
b544f0f9eb feat(loader): Connection.ResourceUrl patch for GetResourceServerURL
Adds a Harmony prefix on CustomPreference.GetResourceServerURL mirroring
the existing GetApplicationServerURL pattern, gated on a new BepInEx
config key Connection.ResourceUrl. Default mirrors prod
(https://shadowverse.akamaized.net/) so the patch is functionally a
no-op until the user opts in.

To redirect at a local SVSim.ContentServer (typically on :5149 per the
project's launchSettings), set:
    [Connection]
    ResourceUrl = http://localhost:5149/

The stock client composes manifest / asset-bundle / sound / movie URLs
as GetResourceServerURL() + "dl/Manifest/{resVer}/{lang}/{plat}/..."
(and similar for Resource/, Sound/) — the trailing slash on the
returned host is load-bearing. Sidestepping the scheme accessor
(GetCDNScheme) is intentional: we supply the full URL with scheme
ourselves, the same shape the ApplicationUrl patch returns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 11:44:40 -04:00
gamer147
44c370d7a8 docs(svsimloader): update data_dumps path references for reorg
Mirror of the outer-repo data_dumps/ reorganization: README now points
at data_dumps/captures/ for traffic_prod.ndjson; the GachaExchangeSweep
comment naming the tradeables capture follows the same path rewrite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 01:22:59 -04:00
gamer147
d4c4a1386f Merge branch 'progression-import-export'
Brings four feature commits onto master:
- export owned cards, decks, items; fix rupy currency key
- descend into envelope data key in user-data dump
- skip empty deck slots in user-data export
- GachaExchangeSweep over full master pack list

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:09:18 -04:00
gamer147
6426c0af77 feat(loader): GachaExchangeSweep over full master pack list
Replaces LeaderSkinPoolSweep. The old sweep drove from /pack/info's
pack_config_list (35 in-catalog packs), missing the off-catalog families
(Throwback, Rotation Select, Premium, anniversary) where the drawrates
parser still has 75 ambiguous card_id joins. The new sweep drives from
Wizard.Data.Master.CardSetNameMgr.GetList() — the full 279 client-master
pack ids — and persists a miss ledger at
BepInEx/svsim-captures/gacha-sweep-misses.json so re-runs skip known-dead
ids. Adds SweepDryRunIds for smoke-testing on a handful of ids before
committing the session to a full ~5min sweep. Capture path unchanged.

Design: docs/superpowers/specs/2026-05-30-gacha-exchange-sweep-design.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:04:02 -04:00
gamer147
3832a20aed feat(loader): skip empty deck slots in user-data export
A /load/index response carries every deck slot, mostly empty placeholders;
emit only the real (non-empty) decks so the dump is a handful of decks
instead of ~100 empty slots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:03:12 -04:00
gamer147
d96a5e42c7 fix(loader): descend into envelope data key in user-data dump
WriteUserDataFromLoadIndex received the full response envelope
{ data_headers, data } but read viewer fields from the top level, so
every SafeGet missed and the dump contained only steam_id. Descend into
the inner data key first (mirrors TryExtractSpecialBattleSettings).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:35:09 -04:00
gamer147
b454d58cf2 feat(loader): export owned cards, decks, items; fix rupy currency key
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:01:06 -04:00
gamer147
8c79725869 fix(loader): narrow IdentityWipe to UDID/VIEWER_ID/SHORT_UDID only
Calling PlayerPrefs.DeleteAll() in IdentityWipe.Execute wiped more
than the three account-keyed entries: it also took out RES_VER (the
Akamai manifest version path), the asset layer's cache-index metadata
in PlayerPrefs, language/sound prefs, and anything else CodeStage
ObscuredPrefs had stashed.

Two observable symptoms on every nuked launch against either a local
or a prod server:

- The client lost track of which asset bundles it had on disk, so
  ResourceDownloader.CheckAndStartNeedDownload prompted "Do you want
  to download the tutorial data? (15.8 MB)" even when the bundles
  were already cached.
- After saying yes, the follow-up
  ShowTutorialBgDownloadDialog prompted "Download data in background?
  (497.1 MB)" for the same reason against the post-tutorial assets.

Narrow IdentityWipe to call ObscuredPrefs.DeleteKey on exactly
"UDID", "VIEWER_ID", and "SHORT_UDID" — the three keys
Cute/Certification.InitializeFileds would clear in the game itself.
Everything else in PlayerPrefs survives, the cache index stays in
sync with the on-disk bundles, and a nuked relaunch behaves like a
Steam-account switch (fresh signup, intact downloads).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:04:44 -04:00
9 changed files with 472 additions and 137 deletions

View File

@@ -79,7 +79,7 @@ BepInEx/svsim-captures/<yyyy-MM-dd_HH-mm-ss>_<host>/
The capture hook decrypts each response before writing, so `traffic.ndjson` is always readable JSON regardless of whether the underlying connection used AES. `DisableEncryption` is therefore not required to make captures inspectable; it only affects what flows over the wire itself.
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/`.
The `traffic_prod.ndjson` checked into `data_dumps/captures/` is a curated paste of one such session, used as the seed source for `SVSim.Bootstrap/Data/prod-captures/`.
## Code layout

View File

@@ -131,6 +131,17 @@ internal static class CaptureWriter
try
{
// SetResponseData hands us the FULL response envelope { data_headers, data }; the
// viewer payload (user_info, user_crystal_count, user_card_list, ...) lives under the
// inner `data` key. Descend into it before extracting — same as
// ExaminationPatches.TryExtractSpecialBattleSettings does. Without this every SafeGet
// below misses and the dump contains nothing but steam_id. The inner payload has no
// top-level `data` key of its own, so this is safe if a caller ever pre-strips it.
if (loadIndexData != null && loadIndexData.IsObject && loadIndexData.Keys.Contains("data"))
{
loadIndexData = loadIndexData["data"];
}
var dump = new Dictionary<string, object>
{
{ "steam_id", _lastSeenSteamId }
@@ -153,7 +164,7 @@ internal static class CaptureWriter
{
var cur = new Dictionary<string, object>();
Copy(crystal, "crystal", cur, "crystals");
Copy(crystal, "rupies", cur, "rupees");
Copy(crystal, "rupy", cur, "rupees");
Copy(crystal, "red_ether", cur, "red_ether");
if (cur.Count > 0) dump["currency"] = cur;
}
@@ -164,6 +175,9 @@ internal static class CaptureWriter
ExtractMyPageList(loadIndexData, dump);
ExtractOwnedLeaderSkins(loadIndexData, dump);
ExtractClasses(loadIndexData, dump);
ExtractOwnedCards(loadIndexData, dump);
ExtractItems(loadIndexData, dump);
ExtractDecks(loadIndexData, dump);
lock (_lock)
{
@@ -278,6 +292,134 @@ internal static class CaptureWriter
if (classes.Count > 0) dump["classes"] = classes;
}
private static void ExtractOwnedCards(JsonData data, Dictionary<string, object> dump)
{
var list = SafeGet(data, "user_card_list");
if (list == null || !list.IsArray) return;
var cards = new List<Dictionary<string, object>>();
for (int i = 0; i < list.Count; i++)
{
var entry = list[i];
var idVal = SafeGet(entry, "card_id");
if (idVal == null) continue;
long cardId;
if (idVal.IsInt) cardId = (int)idVal;
else if (idVal.IsLong) cardId = (long)idVal;
else continue;
var c = new Dictionary<string, object> { { "card_id", cardId } };
var num = SafeGet(entry, "number");
if (num != null)
{
if (num.IsInt) c["count"] = (int)num;
else if (num.IsLong) c["count"] = (int)(long)num;
}
var prot = SafeGet(entry, "is_protected");
if (prot != null)
{
c["is_protected"] =
(prot.IsBoolean && (bool)prot) ||
(prot.IsInt && (int)prot != 0) ||
(prot.IsLong && (long)prot != 0);
}
cards.Add(c);
}
if (cards.Count > 0) dump["owned_cards"] = cards;
}
private static void ExtractItems(JsonData data, Dictionary<string, object> dump)
{
var list = SafeGet(data, "user_item_list");
if (list == null || !list.IsArray) return;
var items = new List<Dictionary<string, object>>();
for (int i = 0; i < list.Count; i++)
{
var entry = list[i];
var idVal = SafeGet(entry, "item_id");
if (idVal == null) continue;
var item = new Dictionary<string, object>();
if (idVal.IsInt) item["item_id"] = (int)idVal;
else if (idVal.IsLong) item["item_id"] = (int)(long)idVal;
else continue;
var num = SafeGet(entry, "number");
if (num != null)
{
if (num.IsInt) item["count"] = (int)num;
else if (num.IsLong) item["count"] = (int)(long)num;
}
items.Add(item);
}
if (items.Count > 0) dump["items"] = items;
}
// /load/index splits decks into one container per format; the format is the KEY, not a
// per-deck field. Values mirror the wire deck_format codes (Wizard/Data.cs FormatConvertApi).
private struct DeckFormatKey
{
public string Key;
public int Format;
public DeckFormatKey(string key, int format) { Key = key; Format = format; }
}
private static readonly DeckFormatKey[] DeckFormatKeys =
{
new DeckFormatKey("user_deck_rotation", 1),
new DeckFormatKey("user_deck_unlimited", 2),
new DeckFormatKey("user_deck_pre_rotation", 3),
new DeckFormatKey("user_deck_crossover", 4),
new DeckFormatKey("user_deck_my_rotation", 5),
};
private static void ExtractDecks(JsonData data, Dictionary<string, object> dump)
{
var decks = new List<Dictionary<string, object>>();
foreach (var fmt in DeckFormatKeys)
{
var container = SafeGet(data, fmt.Key);
var deckList = SafeGet(container, "user_deck_list");
if (deckList == null || !deckList.IsArray) continue;
for (int i = 0; i < deckList.Count; i++)
{
var entry = deckList[i];
// /load/index ships every deck slot, most of them empty placeholders. Skip the
// empty ones — the import drops them anyway, and it keeps the dump to the few real
// decks instead of ~100 empty slots.
var cardArr = ExtractLongArray(entry, "card_id_array");
if (cardArr == null || cardArr.Count == 0) continue;
var d = new Dictionary<string, object> { { "deck_format", fmt.Format } };
Copy(entry, "deck_no", d, "deck_no");
Copy(entry, "deck_name", d, "deck_name");
Copy(entry, "class_id", d, "class_id");
Copy(entry, "sleeve_id", d, "sleeve_id");
Copy(entry, "leader_skin_id", d, "leader_skin_id");
Copy(entry, "is_random_leader_skin", d, "is_random_leader_skin");
Copy(entry, "rotation_id", d, "my_rotation_id"); // UserDeck.rotation_id -> import my_rotation_id
d["card_id_array"] = cardArr;
decks.Add(d);
}
}
if (decks.Count > 0) dump["decks"] = decks;
}
private static List<long> ExtractLongArray(JsonData entry, string key)
{
var arr = SafeGet(entry, key);
if (arr == null || !arr.IsArray) return null;
var ids = new List<long>();
for (int i = 0; i < arr.Count; i++)
{
var v = arr[i];
if (v == null) continue;
if (v.IsInt) ids.Add((int)v);
else if (v.IsLong) ids.Add((long)v);
}
return ids;
}
// Splice the body into the envelope as nested JSON (parseable) or escaped string
// (fallback). Cannot route this through Dictionary<string,object> → JsonMapper.ToJson:
// a LitJson.JsonData value inside such a dict makes the reflection serializer

View File

@@ -1,14 +1,45 @@
extern alias game;
using ObscuredPrefs = CodeStage.AntiCheat.ObscuredTypes.ObscuredPrefs;
using PlayerPrefs = game::UnityEngine.PlayerPrefs;
namespace SVSimLoader;
/// <summary>
/// Narrow identity reset for the local-server test loop. Deletes ONLY the three
/// account-keyed entries in <c>Toolbox.SavedataManager</c>:
/// <list type="bullet">
/// <item>UDID — client-generated GUID identifying this install</item>
/// <item>VIEWER_ID — server-assigned viewer id</item>
/// <item>SHORT_UDID — server-assigned short id</item>
/// </list>
/// <para>
/// Calling <c>PlayerPrefs.DeleteAll()</c> would also wipe RES_VER (the asset manifest
/// version that drives the Akamai CDN path), language/sound prefs, and — most importantly
/// for the local-server loop — whatever cache-index metadata the asset layer stores in
/// PlayerPrefs. That made every nuked launch trigger the 15.8 MB tutorial-asset download
/// prompt followed by the 497 MB background-download prompt, even when the on-disk asset
/// bundles were already there. Narrowing to the three identity keys keeps the asset cache
/// in sync with what prod last served, so a wiped client behaves like a fresh signup
/// against the same RES_VER prod is currently on.
/// </para>
/// <para>
/// <c>ObscuredPrefs.DeleteKey</c> deletes both the obscured-key entry
/// (<c>PlayerPrefs[EncryptKey("UDID")]</c>) and the plain-key entry
/// (<c>PlayerPrefs["UDID"]</c>) for each key, matching how
/// <c>Cute/Certification.InitializeFileds</c> would clear them in the game itself.
/// </para>
/// </summary>
public static class IdentityWipe
{
private static readonly string[] IdentityKeys = { "UDID", "VIEWER_ID", "SHORT_UDID" };
public static void Execute()
{
PlayerPrefs.DeleteAll();
foreach (var key in IdentityKeys)
{
ObscuredPrefs.DeleteKey(key);
}
PlayerPrefs.Save();
}
}

View File

@@ -23,13 +23,14 @@ public static class ExaminationPatches
}
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.
// `data` is the FULL envelope { data_headers, data } (see TryExtractSpecialBattleSettings
// + NetworkTask.cs:108-110). WriteUserDataFromLoadIndex descends into the inner `data`
// key itself, so pass the envelope as-is.
CaptureWriter.WriteUserDataFromLoadIndex(data);
}
if (SvSimConfig.SweepLeaderSkinPools && __instance.Url != null && __instance.Url.EndsWith("/pack/info"))
if (SvSimConfig.SweepGachaExchange && __instance.Url != null && __instance.Url.EndsWith("/pack/info"))
{
LeaderSkinPoolSweep.OnPackInfoResponse(data);
GachaExchangeSweep.OnPackInfoResponse(data);
}
if (__instance.Url != null && IsStorySectionUrl(__instance.Url) &&
(SvSimConfig.SweepMainStory || SvSimConfig.SweepLimitedStory || SvSimConfig.SweepEventStory))

View File

@@ -0,0 +1,247 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
using BepInEx;
using Cute;
using LitJson;
using UnityEngine;
using Wizard;
using Wizard.Scripts.Network.Data.TaskData.SpotCardExchange;
namespace SVSimLoader.Patches;
/// <summary>
/// Replaces the former LeaderSkinPoolSweep. Fires GachaPointExchangeInfoTask
/// (/pack/get_gacha_point_rewards) for every pack id in
/// Wizard.Data.Master.CardSetNameMgr.GetList() — i.e. the full client master
/// list (~279 ids), not just the 35 in-catalog packs /pack/info returns.
///
/// Goal: capture per-pack tradeable card_id lists for off-catalog families
/// (Throwback 80xxx, Rotation Select 97/98xxx, Premium 93xxx, anniversary
/// 92xxx/95xxx) so the drawrates parser's tier-4 disambiguation can resolve
/// the residual ambiguous joins. See
/// docs/superpowers/specs/2026-05-30-gacha-exchange-sweep-design.md.
///
/// Trigger: first /pack/info response of the session (same as the old sweep).
/// Capture path: responses ride the existing ExaminationPatches.SetResponseData
/// EnableTrafficCapture branch into traffic.ndjson — this sweep never calls
/// CaptureWriter directly.
///
/// Misses (result_code != 1) are recorded in a persistent ledger at
/// BepInEx/svsim-captures/gacha-sweep-misses.json so re-runs across sessions
/// don't re-hit dead ids.
///
/// Gated by SvSimConfig.SweepGachaExchange (off by default). Pacing from
/// SvSimConfig.GachaExchangeSweepPacingSeconds (default 0.5, clamped >= 0.1).
/// Smoke-test mode via SvSimConfig.SweepDryRunIds (comma-separated allowlist).
/// </summary>
internal static class GachaExchangeSweep
{
private static bool _sweepStarted;
private static readonly object _lock = new object();
private const string LedgerFileName = "gacha-sweep-misses.json";
private const string LedgerSubdir = "svsim-captures";
public static void OnPackInfoResponse(JsonData _)
{
lock (_lock)
{
if (_sweepStarted) return;
_sweepStarted = true;
}
if (Plugin.Instance == null)
{
Plugin.Log.LogError("GachaExchangeSweep: Plugin.Instance is null — cannot start coroutine.");
return;
}
var ids = BuildIdList();
if (ids.Count == 0)
{
Plugin.Log.LogWarning("GachaExchangeSweep: BuildIdList returned 0 ids — nothing to sweep.");
return;
}
float pacing = Mathf.Max(0.1f, SvSimConfig.GachaExchangeSweepPacingSeconds);
Plugin.Log.LogInfo($"GachaExchangeSweep: queued {ids.Count} ids (pacing={pacing}s).");
Plugin.Instance.StartCoroutine(SweepCoroutine(ids, pacing));
}
/// <summary>
/// Builds the candidate id list: every numeric CardSetName.ID from
/// CardSetNameMgr, minus the persistent miss ledger, optionally
/// intersected with SweepDryRunIds.
/// </summary>
private static List<int> BuildIdList()
{
var all = new HashSet<int>();
var master = Data.Master?.CardSetNameMgr;
if (master == null)
{
Plugin.Log.LogWarning("GachaExchangeSweep: Wizard.Data.Master.CardSetNameMgr is null — master data not loaded yet?");
return new List<int>();
}
var list = master.GetList();
if (list == null)
{
Plugin.Log.LogWarning("GachaExchangeSweep: CardSetNameMgr.GetList() returned null.");
return new List<int>();
}
foreach (var cs in list)
{
if (cs == null || string.IsNullOrEmpty(cs.ID)) continue;
if (int.TryParse(cs.ID, out int id)) all.Add(id);
}
var misses = LoadMissLedger();
all.ExceptWith(misses);
var dryRun = ParseDryRunIds(SvSimConfig.SweepDryRunIds);
if (dryRun.Count > 0)
{
all.IntersectWith(dryRun);
Plugin.Log.LogInfo($"GachaExchangeSweep: SweepDryRunIds active — restricted to {all.Count} of {dryRun.Count} requested ids.");
}
var ordered = new List<int>(all);
ordered.Sort();
return ordered;
}
private static HashSet<int> ParseDryRunIds(string raw)
{
var set = new HashSet<int>();
if (string.IsNullOrEmpty(raw)) return set;
foreach (var part in raw.Split(','))
{
var token = part.Trim();
if (token.Length == 0) continue;
if (int.TryParse(token, out int id)) set.Add(id);
else Plugin.Log.LogWarning($"GachaExchangeSweep: SweepDryRunIds token '{token}' is not an int — skipped.");
}
return set;
}
private static IEnumerator SweepCoroutine(List<int> ids, float pacing)
{
int ok = 0, fail = 0;
var newMisses = new HashSet<int>();
for (int i = 0; i < ids.Count; i++)
{
int id = ids[i];
// odds_gacha_id and parent_gacha_id observed equal in the natural-flow capture
// (data_dumps/captures/traffic_prod_tradeables_capture.ndjson). Pass the same value twice.
var task = new GachaPointExchangeInfoTask();
task.SetParameter(id, id);
Plugin.Log.LogInfo($"GachaExchangeSweep: [{i + 1}/{ids.Count}] pack_id={id}");
yield return Toolbox.NetworkManager.Connect(task, _ => { });
if (task.isServerResultCodeOK())
{
ok++;
}
else
{
fail++;
newMisses.Add(id);
Plugin.Log.LogWarning($"GachaExchangeSweep: pack_id={id} returned result_code={task.GetResultCode()} — recording as miss.");
}
yield return new WaitForSeconds(pacing);
}
int totalLedger = -1;
if (newMisses.Count > 0)
{
try
{
totalLedger = SaveMissLedger(newMisses);
}
catch (Exception e)
{
Plugin.Log.LogError($"GachaExchangeSweep: SaveMissLedger failed: {e}");
}
}
if (totalLedger >= 0)
{
Plugin.Log.LogInfo($"GachaExchangeSweep: complete. ok={ok} fail={fail}/{ids.Count}, ledger now {totalLedger} ids.");
}
else
{
Plugin.Log.LogInfo($"GachaExchangeSweep: complete. ok={ok} fail={fail}/{ids.Count}, ledger unchanged.");
}
}
private static string LedgerPath()
{
return Path.Combine(Paths.BepInExRootPath, LedgerSubdir, LedgerFileName);
}
private static HashSet<int> LoadMissLedger()
{
var result = new HashSet<int>();
var path = LedgerPath();
if (!File.Exists(path)) return result;
try
{
var json = File.ReadAllText(path);
if (string.IsNullOrEmpty(json)) return result;
var data = JsonMapper.ToObject(json);
if (data == null || !data.IsObject || !data.Keys.Contains("miss_ids")) return result;
var arr = data["miss_ids"];
if (arr == null || !arr.IsArray) return result;
for (int i = 0; i < arr.Count; i++)
{
var v = arr[i];
if (v == null) continue;
if (v.IsInt) result.Add((int)v);
else if (v.IsLong) result.Add((int)(long)v);
}
}
catch (Exception e)
{
Plugin.Log.LogError($"GachaExchangeSweep: LoadMissLedger failed (treating as empty): {e}");
return new HashSet<int>();
}
return result;
}
/// <summary>
/// Atomic union-merge save. Reads the existing ledger, unions the new
/// misses in, writes to a temp file, then atomically replaces the dest.
/// Returns the total miss-id count after merge.
/// </summary>
private static int SaveMissLedger(HashSet<int> newMisses)
{
var path = LedgerPath();
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var merged = LoadMissLedger();
merged.UnionWith(newMisses);
var ordered = new List<int>(merged);
ordered.Sort();
var payload = new Dictionary<string, object>
{
{ "miss_ids", ordered },
{ "last_updated", DateTime.UtcNow.ToString("o") }
};
var json = JsonMapper.ToJson(payload);
var tempPath = path + ".tmp";
File.WriteAllText(tempPath, json, new UTF8Encoding(false));
if (File.Exists(path))
{
File.Replace(tempPath, path, null);
}
else
{
File.Move(tempPath, path);
}
return merged.Count;
}
}

View File

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

View File

@@ -30,4 +30,28 @@ public static class UrlPatches
__result = SvSimConfig.ApplicationUrl;
return false;
}
/// <summary>
/// Redirects the asset CDN ("resource server" — #3 of the 4-server topology, shadowverse.
/// akamaized.net in prod) to a configured URL, typically a local SVSim.ContentServer. The
/// stock client composes manifest / asset-bundle / sound / movie URLs as
/// <c>GetResourceServerURL() + "dl/Manifest/{resVer}/{lang}/{plat}/..."</c> (and similar
/// for Resource/, Sound/), so this prefix needs to return the bare host root WITH a
/// trailing slash. Stock <c>GetResourceServerURL</c> returns <c>GetCDNScheme() +
/// _resourceServerUrl</c> — we sidestep both the scheme accessor and the stored host by
/// supplying the full URL ourselves.
/// <para>
/// Default value mirrors prod so this patch is functionally a no-op until the user opts
/// in by changing the BepInEx config. To point at a local content server populated by
/// <c>data_dumps/scripts/content_cdn_mirror.py</c>, set
/// <c>Connection.ResourceUrl=http://localhost:5149/</c>.
/// </para>
/// </summary>
[HarmonyPatch(typeof(CustomPreference), nameof(CustomPreference.GetResourceServerURL))]
[HarmonyPrefix]
public static bool GetResourceServerURL(ref string __result)
{
__result = SvSimConfig.ResourceUrl;
return false;
}
}

View File

@@ -13,6 +13,7 @@ namespace SVSimLoader
internal static ManualLogSource Log;
public static Plugin Instance { get; private set; }
private ConfigEntry<string> _applicationUrl;
private ConfigEntry<string> _resourceUrl;
private ConfigEntry<bool> _disableEncryption;
private void Awake()
{
@@ -22,6 +23,10 @@ namespace SVSimLoader
Logger.LogInfo($"Plugin {PluginInfo.PLUGIN_GUID} is loaded!");
_applicationUrl = Config.Bind("Connection", "ApplicationUrl", "https://utoongaize.shadowverse.jp/shadowverse/",
"The URL to the application server.");
_resourceUrl = Config.Bind("Connection", "ResourceUrl", "https://shadowverse.akamaized.net/",
"The URL to the asset CDN (resource server #3). Must end with a trailing slash. " +
"Default points at the prod Akamai CDN — change to e.g. http://localhost:5149/ to redirect " +
"to a local SVSim.ContentServer populated by data_dumps/scripts/content_cdn_mirror.py.");
_disableEncryption = Config.Bind("Connection", "DisableEncryption", false,
"Whether to disable encrypting HTTP requests");
SvSimConfig.EnableTrafficCapture =
@@ -42,9 +47,15 @@ namespace SVSimLoader
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.SweepGachaExchange =
Config.Bind("Sweeps", "SweepGachaExchange", false,
"On the first /pack/info response of the session, fire /pack/get_gacha_point_rewards (GachaPointExchangeInfoTask) for every pack id in Wizard.Data.Master.CardSetNameMgr.GetList() (~279 ids), minus ids already known to fail from the persistent miss ledger at BepInEx/svsim-captures/gacha-sweep-misses.json. Replaces SweepLeaderSkinPools (which only covered the 35 in-catalog packs from /pack/info's pack_config_list). Responses captured into traffic.ndjson via the existing traffic hook. One-shot per session. WARNING: hits prod API — at 0.5s/request default, full sweep is ~25min wall-clock.").Value;
SvSimConfig.GachaExchangeSweepPacingSeconds =
Config.Bind("Sweeps", "GachaExchangeSweepPacingSeconds", 0.5f,
"Seconds to wait between consecutive /pack/get_gacha_point_rewards requests during the gacha-exchange sweep. Clamped to a minimum of 0.1s. Default 0.5s mirrors the old LeaderSkinPoolSweep pacing.").Value;
SvSimConfig.SweepDryRunIds =
Config.Bind("Sweeps", "SweepDryRunIds", "",
"Optional comma-separated list of pack ids to restrict the gacha-exchange sweep to (e.g. '80008,97002,93025'). Empty = full sweep. Use this to smoke-test the sweep on a handful of known-ambiguous packs before committing the session to a full ~5min run.").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;
@@ -64,6 +75,7 @@ namespace SVSimLoader
Config.Bind("Identity", "NukeIdentityOnStartup", false,
"On plugin Awake (before the game reads PlayerPrefs), wipe all PlayerPrefs via PlayerPrefs.DeleteAll(). Clears the obscured UDID/VIEWER_ID/SHORT_UDID keys that Cute.Certification reads on login, so the next launch behaves like a brand-new install and re-runs SignUpTask. Use this when switching Steam accounts gives a linking error. SIDE EFFECT: also resets language/sound/RES_VER prefs — they're rebuilt from defaults next boot. Recovery files and capture sessions are NOT touched.").Value;
SvSimConfig.ApplicationUrl = _applicationUrl.Value;
SvSimConfig.ResourceUrl = _resourceUrl.Value;
SvSimConfig.DisableEncryption = _disableEncryption.Value;
if (SvSimConfig.NukeIdentityOnStartup)
{
@@ -73,6 +85,7 @@ namespace SVSimLoader
CaptureWriter.Initialize();
Logger.LogInfo($"Capture session directory: {CaptureWriter.SessionDirectory}");
Logger.LogInfo($"Connecting to application server at {_applicationUrl.Value}");
Logger.LogInfo($"Fetching assets from resource server at {_resourceUrl.Value}");
ExceptionLogging.Install();
var harmony = new Harmony(PluginInfo.PLUGIN_GUID);
harmony.PatchAll(Assembly.GetExecutingAssembly());

View File

@@ -3,12 +3,15 @@ namespace SVSimLoader;
public static class SvSimConfig
{
public static string ApplicationUrl { get; set; }
public static string ResourceUrl { 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 SweepGachaExchange { get; set; }
public static float GachaExchangeSweepPacingSeconds { get; set; }
public static string SweepDryRunIds { get; set; }
public static bool SweepMainStory { get; set; }
public static bool SweepLimitedStory { get; set; }
public static bool SweepEventStory { get; set; }