Compare commits

...

14 Commits

Author SHA1 Message Date
gamer147
8d86b45135 Merge branch 'feat/single-machine-two-client-pvp' 2026-06-11 20:23:28 -04:00
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
b3df361f6d feat(loader): add EnableSpinProbe diagnostic for the spin investigation
Logs {event,uri,spin,count} per battle frame to spin-rng.ndjson, where
count is BattleManagerBase.stableRandomCount (cumulative shared-RNG draws,
read before the spin crank). Lets us compare per-turn local draw deltas
against the inbound spin to determine whether prod authors spin from
server-side simulation or it is wire-derivable bookkeeping.

- SpinProbe.cs: HarmonyPrefix on OperateReceive.StartOperate (receive,
  pre-crank) and NetworkBattleSender.EmitMsg (send); stableRandomCount
  read reflectively via AccessTools.
- CaptureWriter.AppendSpinProbe -> spin-rng.ndjson.
- SvSimConfig.EnableSpinProbe + Plugin.cs Config.Bind (default off).

See docs/audits/battle-node-spin-rng-model-2026-06-04.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:24:24 -04:00
gamer147
620ae31582 style(loader): file-scoped namespace + silence Harmony003 false positive 2026-06-03 09:17:37 -04:00
gamer147
800e40344b feat(loader): mutex-skip + per-instance identity + synthetic Steam identity patches
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 09:14:36 -04:00
gamer147
5bc7d6f184 feat(loader): read SVSIM_INSTANCE_ID and derive per-instance identity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 09:12:49 -04:00
gamer147
f0c422aa8f feat(loader): file-backed per-instance identity store 2026-06-03 09:11:13 -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
12 changed files with 734 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

@@ -15,6 +15,7 @@ internal static class CaptureWriter
private static string _cardsPath;
private static string _userDataPath;
private static string _sbsPath;
private static string _spinProbePath;
private static readonly HashSet<string> _seenSbsIds = new HashSet<string>();
private static ulong _lastSeenSteamId;
@@ -33,6 +34,32 @@ internal static class CaptureWriter
_cardsPath = Path.Combine(_sessionDir, "cards.json");
_userDataPath = Path.Combine(_sessionDir, "user-data.json");
_sbsPath = Path.Combine(_sessionDir, "special-battle-settings.ndjson");
_spinProbePath = Path.Combine(_sessionDir, "spin-rng.ndjson");
}
/// <summary>
/// Append one row of the spin/shared-RNG diagnostic (EnableSpinProbe). `event` is "receive"
/// (OperateReceive.StartOperate, logged before the spin crank) or "send" (NetworkBattleSender.EmitMsg).
/// `spin` is the inbound crank count (receive only; -1 when not applicable) and `count` is the
/// cumulative BattleManagerBase.stableRandomCount tally (-1 when no battle manager is live).
/// Offline, per-turn `count` deltas vs `spin` settle whether prod authors spin from server-side
/// simulation or it is derivable from the active player's wire data. See
/// docs/audits/battle-node-spin-rng-model-2026-06-04.md.
/// </summary>
public static void AppendSpinProbe(string eventKind, string uri, int spin, int count)
{
string line = JsonMapper.ToJson(new Dictionary<string, object>
{
{ "ts", DateTime.UtcNow.ToString("o") },
{ "event", eventKind },
{ "uri", uri },
{ "spin", spin },
{ "count", count },
});
lock (_lock)
{
File.AppendAllText(_spinProbePath, line + "\n");
}
}
private static string ExtractHost(string url)
@@ -131,6 +158,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 +191,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 +202,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 +319,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

@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.IO;
namespace SVSimLoader
{
/// <summary>
/// File-backed store for the three account-identity PlayerPrefs keys (UDID, VIEWER_ID,
/// SHORT_UDID), so a second client instance on the same machine gets its own identity
/// instead of sharing the per-Windows-user registry store. Only these three keys are
/// redirected; everything else (RES_VER, language, asset cache) stays in the shared store.
/// Persisted as dependency-free key=value lines — the values are a GUID and two integers,
/// which never contain '=' or newlines.
/// </summary>
public static class InstanceIdentityStore
{
public static readonly string[] Keys = { "UDID", "VIEWER_ID", "SHORT_UDID" };
private static string _path;
private static readonly Dictionary<string, string> _values = new Dictionary<string, string>();
public static void Initialize(string path)
{
_path = path;
_values.Clear();
if (!File.Exists(_path)) return;
foreach (var line in File.ReadAllLines(_path))
{
int eq = line.IndexOf('=');
if (eq <= 0) continue;
string key = line.Substring(0, eq);
string val = line.Substring(eq + 1);
if (System.Array.IndexOf(Keys, key) >= 0) _values[key] = val;
}
}
public static bool TryGet(string key, out string value) => _values.TryGetValue(key, out value);
public static void Set(string key, string value)
{
_values[key] = value;
Save();
}
private static void Save()
{
var lines = new List<string>();
foreach (var kv in _values) lines.Add(kv.Key + "=" + kv.Value);
File.WriteAllLines(_path, lines.ToArray());
}
}
}

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

@@ -0,0 +1,74 @@
using Cute;
using HarmonyLib;
namespace SVSimLoader.Patches;
[HarmonyPatch]
public static class MultiInstancePatches
{
// (1) Defeat the machine-wide single-instance guard. createMutex is private, so target it
// by string name. Prefix returning false skips the original (Application.Quit never runs).
[HarmonyPatch(typeof(BootApp), "createMutex")]
[HarmonyPrefix]
public static bool SkipMutex()
{
if (!SvSimConfig.SecondaryInstance) return true; // primary/normal: run original
Plugin.Log.LogWarning("Multi-instance: skipping BootApp.createMutex single-instance guard.");
return false;
}
// (2) Redirect the three identity keys to the per-instance file store. Other keys fall
// through to the original (return true).
private static bool IsIdentityKey(string key) =>
key == "UDID" || key == "VIEWER_ID" || key == "SHORT_UDID";
[HarmonyPatch(typeof(SavedataManager), nameof(SavedataManager.GetString))]
[HarmonyPrefix]
public static bool GetString(string key, string defaultValue, ref string __result)
{
if (!SvSimConfig.SecondaryInstance || !IsIdentityKey(key)) return true;
__result = InstanceIdentityStore.TryGet(key, out var v) ? v : defaultValue;
return false;
}
[HarmonyPatch(typeof(SavedataManager), nameof(SavedataManager.SetString))]
[HarmonyPrefix]
public static bool SetString(string key, string value)
{
if (!SvSimConfig.SecondaryInstance || !IsIdentityKey(key)) return true;
InstanceIdentityStore.Set(key, value);
return false;
}
[HarmonyPatch(typeof(SavedataManager), nameof(SavedataManager.GetInt))]
[HarmonyPrefix]
public static bool GetInt(string key, int defaultValue, ref int __result)
{
if (!SvSimConfig.SecondaryInstance || !IsIdentityKey(key)) return true;
__result = InstanceIdentityStore.TryGet(key, out var v) && int.TryParse(v, out var i) ? i : defaultValue;
return false;
}
[HarmonyPatch(typeof(SavedataManager), nameof(SavedataManager.SetInt))]
[HarmonyPrefix]
public static bool SetInt(string key, int value)
{
if (!SvSimConfig.SecondaryInstance || !IsIdentityKey(key)) return true;
int v = value;
InstanceIdentityStore.Set(key, v.ToString());
return false;
}
// (3) Force a synthetic Steam identity. setSTEAMPlatformData runs in Certification.Start();
// a postfix overwrites SteamID/SteamSessionTicket (private setters) via Traverse.
[HarmonyPatch(typeof(Certification), "setSTEAMPlatformData")]
[HarmonyPostfix]
public static void ForceSteamIdentity()
{
if (!SvSimConfig.SecondaryInstance) return;
Traverse.Create(typeof(Certification)).Property("SteamID").SetValue(SvSimConfig.FakeSteamId);
Traverse.Create(typeof(Certification)).Property("SteamSessionTicket").SetValue(SvSimConfig.FakeTicket);
Plugin.Log.LogWarning(
$"Multi-instance: forced SteamID={SvSimConfig.FakeSteamId}, ticket={SvSimConfig.FakeTicket}.");
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
namespace SVSimLoader.Patches;
/// <summary>
/// Diagnostic for the battle-node `spin` investigation (gated on EnableSpinProbe). It records, per
/// battle frame, the cumulative shared-RNG draw tally (BattleManagerBase.stableRandomCount) alongside
/// the inbound `spin` crank count, so per-turn `count` deltas can be compared against `spin` offline.
///
/// Why this settles the open question: every shared-RNG draw funnels through StableRandom/
/// StableRandomDouble, which increment stableRandomCount — so that field is an EXACT local draw count
/// (decomp: BattleManagerBase.cs:1581,1592; the only `_stableRandom.` callers). The receiver cranks the
/// shared RNG `spin` times before dispatching each frame (OperateReceive.StartOperate:80-84). If our own
/// turns advance `count` by ~the magnitude of the `spin` the opponent's turns hand us, the draws are
/// client-side and `spin` is plausibly wire-derivable; if our local deltas stay near zero while inbound
/// `spin` is in the tenshundreds, prod authors `spin` from server-side simulation (≈ an engine).
///
/// Run against PROD (servers live until end of June 2026) to capture real `spin`; a local Bot/AI battle
/// only ever sends spin=0. Output: spin-rng.ndjson in the capture session dir.
/// See docs/audits/battle-node-spin-rng-model-2026-06-04.md.
/// </summary>
[HarmonyPatch]
public static class SpinProbe
{
// stableRandomCount is a private int on BattleManagerBase — read it reflectively.
private static readonly FieldInfo CountField =
AccessTools.Field(typeof(BattleManagerBase), "stableRandomCount");
/// <summary>Current cumulative shared-RNG draw tally, or -1 when no battle manager is live / unreadable.</summary>
private static int ReadCount()
{
try
{
var mgr = BattleManagerBase.GetIns();
if (mgr == null || CountField == null) return -1;
return (int)CountField.GetValue(mgr);
}
catch
{
return -1;
}
}
// Receive side: logged BEFORE the spin crank runs, so `count` is the tally entering this frame and
// `spin` is the value about to be applied.
[HarmonyPatch(typeof(OperateReceive), nameof(OperateReceive.StartOperate))]
[HarmonyPrefix]
public static void OnReceiveFrame(NetworkBattleReceiver.ReceiveData receivedData)
{
if (!SvSimConfig.EnableSpinProbe || receivedData == null) return;
try
{
CaptureWriter.AppendSpinProbe("receive", $"{receivedData.dataUri}", receivedData.spin, ReadCount());
}
catch (Exception e)
{
Plugin.Log.LogError(e);
}
}
// Send side: our own emitted frames. `count` shows how much our turn's actions advanced the tally;
// `spin` is not applicable on a send (-1).
[HarmonyPatch(typeof(NetworkBattleSender), "EmitMsg")]
[HarmonyPrefix]
public static void OnSendFrame(NetworkBattleDefine.NetworkBattleURI uri, Dictionary<string, object> dataList = null)
{
if (!SvSimConfig.EnableSpinProbe) return;
try
{
CaptureWriter.AppendSpinProbe("send", $"{uri}", -1, ReadCount());
}
catch (Exception e)
{
Plugin.Log.LogError(e);
}
}
}

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,15 +13,39 @@ 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()
{
Instance = this;
Log = base.Logger;
var instanceRaw = System.Environment.GetEnvironmentVariable("SVSIM_INSTANCE_ID");
if (!string.IsNullOrEmpty(instanceRaw) && int.TryParse(instanceRaw, out var instanceId) && instanceId > 0)
{
SvSimConfig.SecondaryInstance = true;
SvSimConfig.InstanceId = instanceId;
SvSimConfig.FakeSteamId = 900000UL + (ulong)instanceId;
// Ticket must be non-empty, even-length, valid hex (the server HexDecodes it before
// the bypass) and DISTINCT per instance (SteamSessionService caches ticket->steamId
// and rejects a reused ticket under a different steamId).
string hex = SvSimConfig.FakeSteamId.ToString("x");
if (hex.Length % 2 == 1) hex = "0" + hex;
SvSimConfig.FakeTicket = hex;
SvSimConfig.IdentityFilePath =
System.IO.Path.Combine(Paths.ConfigPath, $"svsim-identity-{instanceId}.json");
InstanceIdentityStore.Initialize(SvSimConfig.IdentityFilePath);
Logger.LogWarning(
$"MULTI-INSTANCE MODE: id={instanceId}, fakeSteamId={SvSimConfig.FakeSteamId}, " +
$"identity file={SvSimConfig.IdentityFilePath}. Single-instance mutex will be skipped.");
}
// Plugin startup logic
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 =
@@ -30,6 +54,9 @@ namespace SVSimLoader
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.EnableSpinProbe =
Config.Bind("Capture", "EnableSpinProbe", false,
"Diagnostic for the battle-node `spin` investigation. On every received battle frame (OperateReceive.StartOperate) and every emitted frame (NetworkBattleSender.EmitMsg), append {event, uri, spin, count} to spin-rng.ndjson, where `count` is BattleManagerBase.stableRandomCount (the cumulative shared-RNG draw tally, read before the spin crank). Lets us compare per-turn local draw deltas against the inbound spin to settle whether prod authors spin from server-side simulation or it is wire-derivable. Run against PROD (servers live until end of June 2026) to capture real spin values. See docs/audits/battle-node-spin-rng-model-2026-06-04.md.").Value;
SvSimConfig.DumpCardDB =
Config.Bind("Capture", "DumpCardDB", false,
"Dumps the loaded card master to cards.json in the current capture session directory").Value;
@@ -42,9 +69,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 +97,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 +107,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,16 @@ 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 EnableSpinProbe { 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; }
@@ -17,4 +21,11 @@ public static class SvSimConfig
public static bool ProbeEventSection { get; set; }
public static string StorySectionIdFilter { get; set; }
public static bool NukeIdentityOnStartup { get; set; }
// Multi-instance (same-machine two-client PvP smoke) — driven by the SVSIM_INSTANCE_ID env var.
public static bool SecondaryInstance { get; set; } // true when SVSIM_INSTANCE_ID is set
public static int InstanceId { get; set; } // parsed env value
public static string IdentityFilePath { get; set; } // per-instance identity file
public static ulong FakeSteamId { get; set; } // 900000 + InstanceId
public static string FakeTicket { get; set; } // even-length hex of FakeSteamId
}