Compare commits
17 Commits
34a17a2b6b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d86b45135 | ||
|
|
b544f0f9eb | ||
|
|
b3df361f6d | ||
|
|
620ae31582 | ||
|
|
800e40344b | ||
|
|
5bc7d6f184 | ||
|
|
f0c422aa8f | ||
|
|
44c370d7a8 | ||
|
|
d4c4a1386f | ||
|
|
6426c0af77 | ||
|
|
3832a20aed | ||
|
|
d96a5e42c7 | ||
|
|
b454d58cf2 | ||
|
|
8c79725869 | ||
|
|
8f2ddeab96 | ||
|
|
b1206c874d | ||
|
|
76b7e59489 |
@@ -19,7 +19,8 @@ Built against the official Cygames build (decompiled source in `Shadowverse_Code
|
||||
4. Launch the game once to generate `<game-dir>/BepInEx/config/SVSimLoader.cfg`, then close.
|
||||
5. Edit the config. For local server work:
|
||||
- `[Connection] ApplicationUrl = http://localhost:5148/`
|
||||
- `[Connection] DisableEncryption = true`
|
||||
|
||||
That's it — DCGEngine speaks the same AES-encrypted wire format as the prod server, so the default `DisableEncryption = false` is correct against the local server and against any other compliant emulator. Leave `DisableEncryption` alone unless you're debugging the wire layer itself (see below).
|
||||
|
||||
For prod capture, leave `[Connection]` at defaults and toggle whichever `[Capture]` / `[Sweeps]` flags you need.
|
||||
6. Launch the game. A capture session directory appears under `<game-dir>/BepInEx/svsim-captures/`.
|
||||
@@ -37,7 +38,7 @@ Settings live in `BepInEx/config/SVSimLoader.cfg`, generated on first launch. Th
|
||||
| Key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `ApplicationUrl` | prod URL | Overrides `CustomPreference.GetApplicationServerURL`. Point at `http://localhost:5148/` for the local DCGEngine. |
|
||||
| `DisableEncryption` | `false` | Forces the `encrypt` arg on `NetworkManager.Connect` to false. Local server understands plaintext; prod does not. |
|
||||
| `DisableEncryption` | `false` | Forces the `encrypt` arg on `NetworkManager.Connect` to false, so request/response bodies go over the wire as plain base64(msgpack(...)) instead of base64(AES(msgpack(...))). **You do NOT need to enable this for any server — DCGEngine and any other compliant emulator handle the standard AES path the same way prod does.** It exists for wire-format debugging: makes `traffic.ndjson` and proxy-side inspection readable without round-tripping through `CryptAES`. Leave it at `false` for normal use. |
|
||||
|
||||
### `[Capture]` — passive observe-and-record (safe to leave on)
|
||||
|
||||
@@ -76,7 +77,9 @@ BepInEx/svsim-captures/<yyyy-MM-dd_HH-mm-ss>_<host>/
|
||||
special-battle-settings.ndjson # deduped sbs payloads from story sweeps
|
||||
```
|
||||
|
||||
The `traffic_prod.ndjson` checked into `data_dumps/` is a curated paste of one such session, used as the seed source for `SVSim.Bootstrap/Data/prod-captures/`.
|
||||
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/captures/` is a curated paste of one such session, used as the seed source for `SVSim.Bootstrap/Data/prod-captures/`.
|
||||
|
||||
## Code layout
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -85,25 +112,25 @@ internal static class CaptureWriter
|
||||
|
||||
public static void AppendTraffic(string direction, string url, bool encrypted, string body)
|
||||
{
|
||||
AppendNdjson(_trafficPath, new Dictionary<string, object>
|
||||
string envelope = JsonMapper.ToJson(new Dictionary<string, object>
|
||||
{
|
||||
{ "ts", DateTime.UtcNow.ToString("o") },
|
||||
{ "direction", direction },
|
||||
{ "url", url },
|
||||
{ "encrypted", encrypted },
|
||||
{ "body", body },
|
||||
});
|
||||
AppendLineWithBody(_trafficPath, envelope, body);
|
||||
}
|
||||
|
||||
public static void AppendBattleTraffic(string direction, string uri, string body)
|
||||
{
|
||||
AppendNdjson(_battleTrafficPath, new Dictionary<string, object>
|
||||
string envelope = JsonMapper.ToJson(new Dictionary<string, object>
|
||||
{
|
||||
{ "ts", DateTime.UtcNow.ToString("o") },
|
||||
{ "direction", direction },
|
||||
{ "uri", uri },
|
||||
{ "body", body },
|
||||
});
|
||||
AppendLineWithBody(_battleTrafficPath, envelope, body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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,9 +319,156 @@ internal static class CaptureWriter
|
||||
if (classes.Count > 0) dump["classes"] = classes;
|
||||
}
|
||||
|
||||
private static void AppendNdjson(string path, Dictionary<string, object> entry)
|
||||
private static void ExtractOwnedCards(JsonData data, Dictionary<string, object> dump)
|
||||
{
|
||||
string line = JsonMapper.ToJson(entry);
|
||||
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
|
||||
// mis-bracket and throw "Can't close an object here".
|
||||
private static void AppendLineWithBody(string path, string envelopeJson, string body)
|
||||
{
|
||||
string bodyJson;
|
||||
if (body == null)
|
||||
{
|
||||
bodyJson = "null";
|
||||
}
|
||||
else if (body.Length == 0)
|
||||
{
|
||||
bodyJson = "\"\"";
|
||||
}
|
||||
else
|
||||
{
|
||||
try { bodyJson = JsonMapper.ToObject(body).ToJson(); }
|
||||
catch { bodyJson = JsonMapper.ToJson(body); }
|
||||
}
|
||||
string trimmed = envelopeJson.Substring(0, envelopeJson.Length - 1);
|
||||
string line = trimmed + ",\"body\":" + bodyJson + "}";
|
||||
lock (_lock)
|
||||
{
|
||||
File.AppendAllText(path, line + "\n");
|
||||
|
||||
45
SVSimLoader/IdentityWipe.cs
Normal file
45
SVSimLoader/IdentityWipe.cs
Normal file
@@ -0,0 +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()
|
||||
{
|
||||
foreach (var key in IdentityKeys)
|
||||
{
|
||||
ObscuredPrefs.DeleteKey(key);
|
||||
}
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
}
|
||||
51
SVSimLoader/InstanceIdentityStore.cs
Normal file
51
SVSimLoader/InstanceIdentityStore.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
247
SVSimLoader/Patches/GachaExchangeSweep.cs
Normal file
247
SVSimLoader/Patches/GachaExchangeSweep.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
74
SVSimLoader/Patches/MultiInstancePatches.cs
Normal file
74
SVSimLoader/Patches/MultiInstancePatches.cs
Normal 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}.");
|
||||
}
|
||||
}
|
||||
80
SVSimLoader/Patches/SpinProbe.cs
Normal file
80
SVSimLoader/Patches/SpinProbe.cs
Normal 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 tens–hundreds, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,44 @@ public static class UrlPatches
|
||||
__result = SvSimConfig.ApplicationUrl;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redirects the deck-builder server (shadowverse-portal.com in prod) to our app server so
|
||||
/// the deck-code mint/resolve pair lands on the emulator's <c>DeckBuilderController</c>.
|
||||
/// Both client tasks (<c>GenerateDeckCodeTask</c>, <c>GetDeckDataFromCodeTask</c>) build
|
||||
/// their URL as <c>GetDeckBuilderServerURL() + ApiList[type]</c> where the right-hand side
|
||||
/// is the bare <c>deck_code?format=msgpack</c> / <c>deck?format=msgpack</c> path — matches
|
||||
/// our controller routes once this prefix returns the local server's base URL.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(CustomPreference), nameof(CustomPreference.GetDeckBuilderServerURL))]
|
||||
[HarmonyPrefix]
|
||||
public static bool GetDeckBuilderServerURL(ref string __result)
|
||||
{
|
||||
__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;
|
||||
}
|
||||
}
|
||||
@@ -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 ~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;
|
||||
@@ -60,11 +93,21 @@ namespace SVSimLoader
|
||||
SvSimConfig.StorySectionIdFilter =
|
||||
Config.Bind("Sweeps", "StorySectionIdFilter", "",
|
||||
"Optional comma-separated list of section IDs to restrict the story sweep to (e.g. '14,19,20'). Empty = sweep all sections. Useful for resuming a previous run that hit MAX_PASSES_PER_PAIR on specific sections without re-sweeping everything.").Value;
|
||||
SvSimConfig.NukeIdentityOnStartup =
|
||||
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)
|
||||
{
|
||||
Logger.LogWarning("NukeIdentityOnStartup is enabled — wiping all PlayerPrefs (identity + settings).");
|
||||
IdentityWipe.Execute();
|
||||
}
|
||||
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());
|
||||
|
||||
@@ -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; }
|
||||
@@ -16,4 +20,12 @@ public static class SvSimConfig
|
||||
public static bool ProbeLimitedSection { get; set; }
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user