diff --git a/SVSimLoader/Patches/ExaminationPatches.cs b/SVSimLoader/Patches/ExaminationPatches.cs
index cfca631..0c2134a 100644
--- a/SVSimLoader/Patches/ExaminationPatches.cs
+++ b/SVSimLoader/Patches/ExaminationPatches.cs
@@ -28,9 +28,9 @@ public static class ExaminationPatches
// 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))
diff --git a/SVSimLoader/Patches/GachaExchangeSweep.cs b/SVSimLoader/Patches/GachaExchangeSweep.cs
new file mode 100644
index 0000000..b3180c9
--- /dev/null
+++ b/SVSimLoader/Patches/GachaExchangeSweep.cs
@@ -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;
+
+///
+/// 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).
+///
+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));
+ }
+
+ ///
+ /// Builds the candidate id list: every numeric CardSetName.ID from
+ /// CardSetNameMgr, minus the persistent miss ledger, optionally
+ /// intersected with SweepDryRunIds.
+ ///
+ private static List BuildIdList()
+ {
+ var all = new HashSet();
+ 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();
+ }
+ var list = master.GetList();
+ if (list == null)
+ {
+ Plugin.Log.LogWarning("GachaExchangeSweep: CardSetNameMgr.GetList() returned null.");
+ return new List();
+ }
+ 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(all);
+ ordered.Sort();
+ return ordered;
+ }
+
+ private static HashSet ParseDryRunIds(string raw)
+ {
+ var set = new HashSet();
+ 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 ids, float pacing)
+ {
+ int ok = 0, fail = 0;
+ var newMisses = new HashSet();
+ 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/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 LoadMissLedger()
+ {
+ var result = new HashSet();
+ 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();
+ }
+ return result;
+ }
+
+ ///
+ /// 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.
+ ///
+ private static int SaveMissLedger(HashSet 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(merged);
+ ordered.Sort();
+
+ var payload = new Dictionary
+ {
+ { "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;
+ }
+}
diff --git a/SVSimLoader/Patches/LeaderSkinPoolSweep.cs b/SVSimLoader/Patches/LeaderSkinPoolSweep.cs
deleted file mode 100644
index 55ae4cc..0000000
--- a/SVSimLoader/Patches/LeaderSkinPoolSweep.cs
+++ /dev/null
@@ -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;
-
-///
-/// Fires GachaPointExchangeInfoTask (pack/get_gacha_point_rewards) for every
-/// parent_gacha_id in /pack/info's pack_config_list, on the first /pack/info response
-/// of the session. Responses are captured by the existing traffic hook in
-/// ExaminationPatches.SetResponseData — no extra writer needed.
-///
-/// Originally targeted pack/get_leader_skin_owned_status (Plan B in the leader-cards
-/// design doc), but that endpoint is server-gated on an active skin-select offering
-/// and returned result_code=205 across all 35 packs in the May 2026 catalog. Switched
-/// to pack/get_gacha_point_rewards which exposes the same card_id → leader_skin_id
-/// mapping (via reward_type=10 entries) without the gate, and additionally yields
-/// emblem/sleeve associations for every tradeable card.
-///
-/// Gated by SvSimConfig.SweepLeaderSkinPools (off by default).
-/// Hits a prod API — keep pacing conservative.
-///
-internal static class LeaderSkinPoolSweep
-{
- private static bool _sweepStarted;
- private static readonly object _lock = new object();
-
- public static void OnPackInfoResponse(JsonData data)
- {
- lock (_lock)
- {
- if (_sweepStarted) return;
- _sweepStarted = true;
- }
-
- var ids = ExtractParentGachaIds(data);
- if (ids.Count == 0)
- {
- Plugin.Log.LogWarning("LeaderSkinPoolSweep: /pack/info had no pack_config_list — nothing to sweep.");
- return;
- }
- if (Plugin.Instance == null)
- {
- Plugin.Log.LogError("LeaderSkinPoolSweep: Plugin.Instance is null — cannot start coroutine.");
- return;
- }
- Plugin.Log.LogInfo($"LeaderSkinPoolSweep: queued {ids.Count} packs for /pack/get_gacha_point_rewards sweep.");
- Plugin.Instance.StartCoroutine(SweepCoroutine(ids));
- }
-
- private static List ExtractParentGachaIds(JsonData data)
- {
- var ids = new List();
- if (data == null || !data.IsObject)
- {
- Plugin.Log.LogWarning("LeaderSkinPoolSweep: /pack/info response data was null or non-object.");
- return ids;
- }
-
- // Defensive: the data parameter to SetResponseData might be either the inner `data`
- // payload (matching the DumpUserData assumption) or the full envelope with data_headers
- // outside. Different endpoints in this codebase seem to differ; check both shapes.
- JsonData list = null;
- if (data.Keys.Contains("pack_config_list"))
- {
- list = data["pack_config_list"];
- }
- else if (data.Keys.Contains("data") && data["data"] != null && data["data"].IsObject
- && data["data"].Keys.Contains("pack_config_list"))
- {
- list = data["data"]["pack_config_list"];
- }
-
- if (list == null)
- {
- var keys = string.Join(", ", data.Keys);
- Plugin.Log.LogWarning($"LeaderSkinPoolSweep: pack_config_list not found at either data.pack_config_list or data.data.pack_config_list. Top-level keys observed: [{keys}]");
- return ids;
- }
- if (!list.IsArray)
- {
- Plugin.Log.LogWarning($"LeaderSkinPoolSweep: pack_config_list was found but is not an array (type tag mismatch).");
- return ids;
- }
-
- for (int i = 0; i < list.Count; i++)
- {
- var item = list[i];
- if (item == null || !item.IsObject || !item.Keys.Contains("parent_gacha_id")) continue;
- var v = item["parent_gacha_id"];
- if (v.IsInt) ids.Add((int)v);
- else if (v.IsLong) ids.Add((int)(long)v);
- }
- return ids;
- }
-
- private static IEnumerator SweepCoroutine(List parentGachaIds)
- {
- int ok = 0, fail = 0;
- for (int i = 0; i < parentGachaIds.Count; i++)
- {
- int id = parentGachaIds[i];
- // odds_gacha_id and parent_gacha_id observed equal in the natural-flow capture
- // (data_dumps/traffic_prod_tradeables_capture.ndjson). Pass the same value twice.
- GachaPointExchangeInfoTask task = new GachaPointExchangeInfoTask();
- task.SetParameter(id, id);
- Plugin.Log.LogInfo($"LeaderSkinPoolSweep: [{i + 1}/{parentGachaIds.Count}] parent_gacha_id={id}");
- yield return Toolbox.NetworkManager.Connect(task, _ => { });
- if (task.isServerResultCodeOK())
- {
- ok++;
- }
- else
- {
- fail++;
- Plugin.Log.LogWarning($"LeaderSkinPoolSweep: parent_gacha_id={id} returned result_code={task.GetResultCode()}");
- }
- // 0.5s per request is conservative pacing for a prod API. 35 packs ≈ 18s total.
- yield return new WaitForSeconds(0.5f);
- }
- Plugin.Log.LogInfo($"LeaderSkinPoolSweep: complete. ok={ok} fail={fail}/{parentGachaIds.Count}");
- }
-}
diff --git a/SVSimLoader/Plugin.cs b/SVSimLoader/Plugin.cs
index 6d33e38..0d5a75d 100644
--- a/SVSimLoader/Plugin.cs
+++ b/SVSimLoader/Plugin.cs
@@ -42,9 +42,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 ~2–5min 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;
diff --git a/SVSimLoader/SvSimConfig.cs b/SVSimLoader/SvSimConfig.cs
index 10ce8c4..a0804af 100644
--- a/SVSimLoader/SvSimConfig.cs
+++ b/SVSimLoader/SvSimConfig.cs
@@ -8,7 +8,9 @@ public static class SvSimConfig
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; }