init
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>disable</Nullable>
|
||||
<AssemblyName>BD2CaptureEnvironment</AssemblyName>
|
||||
<RootNamespace>Bd2CaptureEnvironment</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<Target Name="RequireGameDir" BeforeTargets="ResolveReferences" Condition="'$(GameDir)' == ''">
|
||||
<Error Text="GameDir is required. Build with -p:GameDir=<official-client-directory>." />
|
||||
</Target>
|
||||
<ItemGroup>
|
||||
<Reference Include="BepInEx">
|
||||
<HintPath>$(GameDir)\BepInEx\core\BepInEx.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(GameDir)\BepInEx\core\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.CoreModule">
|
||||
<HintPath>$(GameDir)\BrownDust II_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine">
|
||||
<HintPath>$(GameDir)\BrownDust II_Data\Managed\UnityEngine.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UnityWebRequestModule">
|
||||
<HintPath>$(GameDir)\BrownDust II_Data\Managed\UnityEngine.UnityWebRequestModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Google.Protobuf">
|
||||
<HintPath>$(GameDir)\BrownDust II_Data\Managed\Google.Protobuf.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,398 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using BepInEx;
|
||||
using BepInEx.Logging;
|
||||
using Google.Protobuf;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
namespace Bd2CaptureEnvironment;
|
||||
|
||||
[BepInPlugin(Guid, Name, Version)]
|
||||
public sealed class Plugin : BaseUnityPlugin
|
||||
{
|
||||
public const string Guid = "bd2.capture.environment";
|
||||
public const string Name = "BD2 Capture Environment";
|
||||
public const string Version = "0.1.1";
|
||||
|
||||
private const int MaxBodyBytes = 16 * 1024 * 1024;
|
||||
private const string PlayerPrefsPrefix = "BD2OfficialCapture23413:";
|
||||
private static readonly object FileLock = new object();
|
||||
private static ManualLogSource Log;
|
||||
private static string CaptureDirectory;
|
||||
private static string JsonlPath;
|
||||
private static string IsolatedDataDirectory;
|
||||
private static string SharedGameDataDirectory;
|
||||
private static long Sequence;
|
||||
private static long LastApiRequest;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
try
|
||||
{
|
||||
Log = Logger;
|
||||
string root = Paths.GameRootPath;
|
||||
IsolatedDataDirectory = Path.GetFullPath(Path.Combine(root, "IsolatedUserData"));
|
||||
SharedGameDataDirectory = ReadRequiredArgument("-bd2SharedGameData");
|
||||
if (!Directory.Exists(SharedGameDataDirectory))
|
||||
throw new DirectoryNotFoundException(
|
||||
"Shared GameData directory not found: " + SharedGameDataDirectory);
|
||||
CaptureDirectory = Path.GetFullPath(Path.Combine(
|
||||
root, "Capture", DateTime.Now.ToString("yyyyMMdd-HHmmss")));
|
||||
JsonlPath = Path.Combine(CaptureDirectory, "capture.jsonl");
|
||||
Directory.CreateDirectory(IsolatedDataDirectory);
|
||||
Directory.CreateDirectory(Path.Combine(CaptureDirectory, "bodies"));
|
||||
|
||||
var harmony = new Harmony(Guid);
|
||||
InstallStorageIsolation(harmony);
|
||||
string effectiveDataPath = Application.persistentDataPath;
|
||||
if (!string.Equals(
|
||||
Path.GetFullPath(effectiveDataPath).TrimEnd(Path.DirectorySeparatorChar),
|
||||
IsolatedDataDirectory.TrimEnd(Path.DirectorySeparatorChar),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException(
|
||||
"persistentDataPath isolation verification failed: " + effectiveDataPath);
|
||||
InstallRawCapture(harmony);
|
||||
InstallPlaintextCapture(harmony);
|
||||
WriteMetadata();
|
||||
Logger.LogInfo("Official capture environment active");
|
||||
Logger.LogInfo("persistentDataPath => " + IsolatedDataDirectory);
|
||||
Logger.LogInfo("shared GameData => " + SharedGameDataDirectory);
|
||||
Logger.LogInfo("capture => " + CaptureDirectory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Capture environment setup failed: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadRequiredArgument(string name)
|
||||
{
|
||||
string[] arguments = Environment.GetCommandLineArgs();
|
||||
for (int i = 0; i + 1 < arguments.Length; i++)
|
||||
{
|
||||
if (string.Equals(arguments[i], name, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(arguments[i + 1]))
|
||||
return Path.GetFullPath(arguments[i + 1]);
|
||||
}
|
||||
throw new ArgumentException("Required command-line argument is missing: " + name);
|
||||
}
|
||||
|
||||
private static void InstallStorageIsolation(Harmony harmony)
|
||||
{
|
||||
PropertyInfo persistent = typeof(Application).GetProperty(
|
||||
"persistentDataPath", BindingFlags.Static | BindingFlags.Public);
|
||||
MethodInfo getter = persistent?.GetGetMethod();
|
||||
if (getter == null)
|
||||
throw new MissingMethodException("Application.persistentDataPath getter not found");
|
||||
harmony.Patch(getter, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(PersistentDataPathPrefix)));
|
||||
|
||||
var names = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"GetString", "SetString", "GetInt", "SetInt", "GetFloat", "SetFloat",
|
||||
"HasKey", "DeleteKey"
|
||||
};
|
||||
foreach (MethodInfo method in typeof(PlayerPrefs).GetMethods(
|
||||
BindingFlags.Static | BindingFlags.Public))
|
||||
{
|
||||
ParameterInfo[] parameters = method.GetParameters();
|
||||
if (!names.Contains(method.Name) || parameters.Length == 0 ||
|
||||
parameters[0].ParameterType != typeof(string))
|
||||
continue;
|
||||
harmony.Patch(method, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(PlayerPrefsKeyPrefix)));
|
||||
}
|
||||
|
||||
MethodInfo deleteAll = typeof(PlayerPrefs).GetMethod(
|
||||
"DeleteAll", BindingFlags.Static | BindingFlags.Public,
|
||||
null, Type.EmptyTypes, null);
|
||||
if (deleteAll != null)
|
||||
harmony.Patch(deleteAll, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(BlockPlayerPrefsDeleteAll)));
|
||||
}
|
||||
|
||||
private static bool PersistentDataPathPrefix(ref string __result)
|
||||
{
|
||||
__result = IsolatedDataDirectory;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void PlayerPrefsKeyPrefix(ref string key)
|
||||
{
|
||||
if (key != null && !key.StartsWith(PlayerPrefsPrefix, StringComparison.Ordinal))
|
||||
key = PlayerPrefsPrefix + key;
|
||||
}
|
||||
|
||||
private static bool BlockPlayerPrefsDeleteAll()
|
||||
{
|
||||
Log?.LogWarning("Blocked PlayerPrefs.DeleteAll to protect the primary client profile");
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void InstallRawCapture(Harmony harmony)
|
||||
{
|
||||
MethodInfo send = typeof(UnityWebRequest).GetMethod(
|
||||
"SendWebRequest", BindingFlags.Instance | BindingFlags.Public,
|
||||
null, Type.EmptyTypes, null);
|
||||
if (send == null)
|
||||
throw new MissingMethodException("UnityWebRequest.SendWebRequest not found");
|
||||
harmony.Patch(send,
|
||||
prefix: new HarmonyMethod(typeof(Plugin), nameof(SendWebRequestPrefix)),
|
||||
postfix: new HarmonyMethod(typeof(Plugin), nameof(SendWebRequestPostfix)));
|
||||
}
|
||||
|
||||
private sealed class RequestState
|
||||
{
|
||||
public long Id;
|
||||
public string Method;
|
||||
public string Host;
|
||||
public string Path;
|
||||
public bool Capture;
|
||||
}
|
||||
|
||||
private static void SendWebRequestPrefix(UnityWebRequest __instance, out RequestState __state)
|
||||
{
|
||||
__state = null;
|
||||
try
|
||||
{
|
||||
if (__instance == null || !TrySafeUri(__instance.url, out Uri uri) ||
|
||||
!IsGameApi(uri, __instance.method))
|
||||
return;
|
||||
var state = new RequestState
|
||||
{
|
||||
Id = Interlocked.Increment(ref Sequence),
|
||||
Method = __instance.method ?? "",
|
||||
Host = uri.Host,
|
||||
Path = uri.AbsolutePath,
|
||||
Capture = true
|
||||
};
|
||||
__state = state;
|
||||
Interlocked.Exchange(ref LastApiRequest, state.Id);
|
||||
byte[] body = null;
|
||||
try { body = __instance.uploadHandler?.data; } catch { }
|
||||
string file = SaveBody(state.Id, "req", state.Path, body);
|
||||
Emit("req", state.Id, state.Method, state.Host, state.Path, 0,
|
||||
body?.Length ?? 0, file, null, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("request capture failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SendWebRequestPostfix(
|
||||
UnityWebRequest __instance, UnityWebRequestAsyncOperation __result,
|
||||
RequestState __state)
|
||||
{
|
||||
if (__state == null || !__state.Capture || __result == null)
|
||||
return;
|
||||
__result.completed += delegate
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] body = null;
|
||||
string error = null;
|
||||
long responseCode = 0;
|
||||
string result = null;
|
||||
try { body = __instance.downloadHandler?.data; }
|
||||
catch (Exception ex) { error = AppendError(error, ex.Message); }
|
||||
try { responseCode = (long)__instance.responseCode; }
|
||||
catch (Exception ex) { error = AppendError(error, ex.Message); }
|
||||
try { result = __instance.result.ToString(); }
|
||||
catch (Exception ex) { error = AppendError(error, ex.Message); }
|
||||
try { error = AppendError(error, __instance.error); }
|
||||
catch (Exception ex) { error = AppendError(error, ex.Message); }
|
||||
string file = SaveBody(__state.Id, "resp", __state.Path, body);
|
||||
Emit("resp", __state.Id, __state.Method, __state.Host, __state.Path,
|
||||
responseCode, body?.Length ?? 0, file, result, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("response capture failed: " + ex.Message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string AppendError(string current, string next)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(next)) return current;
|
||||
return string.IsNullOrWhiteSpace(current) ? next : current + "; " + next;
|
||||
}
|
||||
|
||||
private static void InstallPlaintextCapture(Harmony harmony)
|
||||
{
|
||||
Type manager = FindType("BDNetwork.NetworkManager");
|
||||
MethodInfo gameDataPath = manager?.GetMethod("GetPachedGameDataPath",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
if (gameDataPath == null)
|
||||
throw new MissingMethodException("NetworkManager.GetPachedGameDataPath not found");
|
||||
harmony.Patch(gameDataPath, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(GameDataPathPrefix)));
|
||||
Log?.LogInfo("Shared GameData path hook installed");
|
||||
|
||||
MethodInfo send = manager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
|
||||
.FirstOrDefault(method => method.Name == "Send" &&
|
||||
method.GetParameters().Length == 6 &&
|
||||
typeof(IMessage).IsAssignableFrom(method.GetParameters()[0].ParameterType));
|
||||
if (send != null)
|
||||
{
|
||||
harmony.Patch(send, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(GameSendPrefix)));
|
||||
Log?.LogInfo("Plaintext request capture installed");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log?.LogWarning("NetworkManager.Send plaintext hook not found");
|
||||
}
|
||||
|
||||
MethodInfo decrypt = manager?.GetMethod("AESDecrypt256",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
if (decrypt != null)
|
||||
{
|
||||
harmony.Patch(decrypt, postfix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(DecryptPostfix)));
|
||||
Log?.LogInfo("Plaintext response capture installed");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log?.LogWarning("NetworkManager.AESDecrypt256 hook not found");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool GameDataPathPrefix(ref string __result)
|
||||
{
|
||||
__result = SharedGameDataDirectory;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void GameSendPrefix(IMessage __0)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__0 == null) return;
|
||||
byte[] body = __0.ToByteArray();
|
||||
long id = Interlocked.Increment(ref Sequence);
|
||||
string type = __0.GetType().FullName ?? __0.GetType().Name;
|
||||
string file = SaveBody(id, "proto_req", type, body);
|
||||
Emit("proto_req", id, "", "", "", 0, body.Length, file, null, type);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("plaintext request capture failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DecryptPostfix(string __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(__result)) return;
|
||||
byte[] body = Convert.FromBase64String(__result);
|
||||
long id = Interlocked.Read(ref LastApiRequest);
|
||||
string file = SaveBody(id, "proto_resp", "protobuf", body);
|
||||
Emit("proto_resp", id, "", "", "", 0, body.Length, file, null, null);
|
||||
}
|
||||
catch (FormatException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("plaintext response capture failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsGameApi(Uri uri, string method)
|
||||
{
|
||||
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||
return false;
|
||||
if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
string host = uri.Host ?? "";
|
||||
return host.Equals("bd2.pmang.cloud", StringComparison.OrdinalIgnoreCase) ||
|
||||
host.EndsWith(".bd2.pmang.cloud", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TrySafeUri(string value, out Uri uri)
|
||||
{
|
||||
return Uri.TryCreate(value, UriKind.Absolute, out uri);
|
||||
}
|
||||
|
||||
private static string SaveBody(long id, string phase, string path, byte[] body)
|
||||
{
|
||||
if (body == null || body.Length == 0 || body.Length > MaxBodyBytes)
|
||||
return null;
|
||||
string safe = new string((path ?? "body")
|
||||
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_').ToArray()).Trim('_');
|
||||
if (safe.Length == 0) safe = "body";
|
||||
if (safe.Length > 70) safe = safe.Substring(safe.Length - 70);
|
||||
string stem = id.ToString("D6") + "_" + phase + "_" + safe;
|
||||
string name;
|
||||
lock (FileLock)
|
||||
{
|
||||
name = stem + ".bin";
|
||||
int duplicate = 2;
|
||||
while (File.Exists(Path.Combine(CaptureDirectory, "bodies", name)))
|
||||
name = stem + "_" + duplicate++ + ".bin";
|
||||
File.WriteAllBytes(Path.Combine(CaptureDirectory, "bodies", name), body);
|
||||
}
|
||||
return "bodies/" + name;
|
||||
}
|
||||
|
||||
private static void Emit(string ev, long id, string method, string host,
|
||||
string path, long status, int length, string rawFile, string result,
|
||||
string note)
|
||||
{
|
||||
string json = "{" +
|
||||
"\"ts\":\"" + Escape(DateTime.Now.ToString("O")) + "\"," +
|
||||
"\"ev\":\"" + Escape(ev) + "\"," +
|
||||
"\"id\":" + id + "," +
|
||||
"\"method\":\"" + Escape(method) + "\"," +
|
||||
"\"host\":\"" + Escape(host) + "\"," +
|
||||
"\"path\":\"" + Escape(path) + "\"," +
|
||||
"\"status\":" + status + "," +
|
||||
"\"length\":" + length + "," +
|
||||
"\"raw_file\":" + JsonString(rawFile) + "," +
|
||||
"\"result\":" + JsonString(result) + "," +
|
||||
"\"note\":" + JsonString(note) + "}";
|
||||
lock (FileLock)
|
||||
File.AppendAllText(JsonlPath, json + Environment.NewLine, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static string JsonString(string value)
|
||||
{
|
||||
return value == null ? "null" : "\"" + Escape(value) + "\"";
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
if (value == null) return "";
|
||||
return value.Replace("\\", "\\\\").Replace("\"", "\\\"")
|
||||
.Replace("\r", "\\r").Replace("\n", "\\n");
|
||||
}
|
||||
|
||||
private static Type FindType(string fullName)
|
||||
{
|
||||
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
Type type = assembly.GetType(fullName, false);
|
||||
if (type != null) return type;
|
||||
}
|
||||
try { return Assembly.Load("Assembly-CSharp")?.GetType(fullName, false); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static void WriteMetadata()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(CaptureDirectory, "README.txt"),
|
||||
"BD2 2.34.13 official API capture.\r\n" +
|
||||
"No Cookie/Authorization headers or URL query strings are recorded.\r\n" +
|
||||
"Raw protobuf/account bodies can still contain private account data. Do not share this directory.\r\n" +
|
||||
"persistentDataPath=" + IsolatedDataDirectory + "\r\n",
|
||||
new UTF8Encoding(false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# BD2 official capture environment plugin
|
||||
|
||||
This BepInEx plugin is only for the separately extracted official 2.34.13 client.
|
||||
|
||||
- redirects `Application.persistentDataPath` to `<game>/IsolatedUserData`;
|
||||
- prefixes game `PlayerPrefs` keys with `BD2OfficialCapture23413:`;
|
||||
- keeps account/profile/Neo/Intro data isolated while patching
|
||||
`NetworkManager.GetPachedGameDataPath()` to the existing `Data/t` and while
|
||||
the launcher validates junctions for shared `SoundData`, Addressables,
|
||||
VideoData and the localized `StreamingAssets/aa` bundle store;
|
||||
- captures BD2 API request/response bodies without a proxy or TLS certificate;
|
||||
- captures protobuf requests before encryption and responses after AES decryption;
|
||||
- omits Cookie/Authorization headers and URL query strings;
|
||||
- skips GET/static resources and bodies larger than 16 MiB.
|
||||
|
||||
HTTP events and protobuf events have independent IDs. A Batch response can
|
||||
decrypt many protobuf messages under one HTTP request ID; repeated bodies get a
|
||||
numeric filename suffix and are never overwritten.
|
||||
|
||||
The raw body files may still contain private account data. Do not publish the
|
||||
generated `Capture` directory. This plugin does not redirect the game API and
|
||||
must not be installed together with `BD2LocalIdentity.dll` in the official
|
||||
comparison client.
|
||||
|
||||
Unity starts writing its default Player log before BepInEx can patch the data
|
||||
path. Always launch through `tools/python/start-official-capture.ps1`; it supplies an
|
||||
explicit isolated `-logFile` path, passes the shared GameData location to the
|
||||
plugin and validates all static-data junctions. The launcher requires explicit
|
||||
official-client and local-client directory arguments.
|
||||
|
||||
Pass them explicitly, for example:
|
||||
|
||||
```powershell
|
||||
.\tools\python\start-official-capture.ps1 `
|
||||
-GameDir "<official-client-directory>" `
|
||||
-LocalClientDir "<local-client-directory>"
|
||||
```
|
||||
|
||||
The launcher derives the shared user-data directory for the current Windows
|
||||
account; `-SharedDataDir` overrides it when the game uses another location.
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>disable</Nullable>
|
||||
<AssemblyName>BD2LocalIdentity</AssemblyName>
|
||||
<RootNamespace>Bd2LocalIdentity</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<Target Name="RequireGameDir" BeforeTargets="ResolveReferences" Condition="'$(GameDir)' == ''">
|
||||
<Error Text="GameDir is required. Build with -p:GameDir=<local-client-directory>." />
|
||||
</Target>
|
||||
<ItemGroup>
|
||||
<Reference Include="BepInEx">
|
||||
<HintPath>$(GameDir)\BepInEx\core\BepInEx.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(GameDir)\BepInEx\core\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.CoreModule">
|
||||
<HintPath>$(GameDir)\BrownDust II_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine">
|
||||
<HintPath>$(GameDir)\BrownDust II_Data\Managed\UnityEngine.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,318 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using BepInEx;
|
||||
using BepInEx.Logging;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Bd2LocalIdentity;
|
||||
|
||||
[BepInPlugin(Guid, Name, Version)]
|
||||
public sealed class Plugin : BaseUnityPlugin
|
||||
{
|
||||
public const string Guid = "bd2.localidentity";
|
||||
public const string Name = "BD2 Local Identity";
|
||||
public const string Version = "0.4.0";
|
||||
private const string LocalServerURL = "http://127.0.0.1:8080/game/";
|
||||
private static ManualLogSource Log;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
try
|
||||
{
|
||||
Log = Logger;
|
||||
// The non-SDK branch creates/uses this local token and calls
|
||||
// SendMaintenanceInfo directly, bypassing Neon account UI.
|
||||
PlayerPrefs.SetString("AccessToken", "bd2-local-development-user");
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Type appManager = FindType("AppManager");
|
||||
PropertyInfo useSdk = appManager?.GetProperty(
|
||||
"ὬὡὬὢὩὬὬὧὨὩὦ",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
MethodInfo getter = useSdk?.GetGetMethod();
|
||||
if (getter == null)
|
||||
{
|
||||
throw new MissingMethodException("AppManager.UseSdk getter was not found (client version mismatch)");
|
||||
}
|
||||
|
||||
MethodInfo prefix = typeof(Plugin).GetMethod(
|
||||
nameof(UseSdkPrefix),
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
Harmony harmony = new Harmony(Guid);
|
||||
harmony.Patch(getter, prefix: new HarmonyMethod(prefix));
|
||||
|
||||
Type introUI = FindType("IntroUI");
|
||||
MethodInfo sendMaintenance = introUI?.GetMethod(
|
||||
"SendMaintenanceInfo",
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
null,
|
||||
new[] { typeof(bool) },
|
||||
null);
|
||||
MethodInfo maintenancePrefix = typeof(Plugin).GetMethod(
|
||||
nameof(SendMaintenancePrefix),
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
if (sendMaintenance == null || maintenancePrefix == null)
|
||||
{
|
||||
throw new MissingMethodException("IntroUI.SendMaintenanceInfo(bool) was not found");
|
||||
}
|
||||
harmony.Patch(sendMaintenance, prefix: new HarmonyMethod(maintenancePrefix));
|
||||
|
||||
MethodInfo finishMaintenance = introUI?.GetMethod(
|
||||
"OnFinishMaintenanceRequest",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
if (finishMaintenance == null)
|
||||
{
|
||||
throw new MissingMethodException("IntroUI.OnFinishMaintenanceRequest was not found");
|
||||
}
|
||||
harmony.Patch(
|
||||
finishMaintenance,
|
||||
postfix: new HarmonyMethod(typeof(Plugin), nameof(OnFinishMaintenancePostfix)));
|
||||
|
||||
MethodInfo falseTimeoutTelemetry = introUI?.GetMethod(
|
||||
"ὭὭὦὫὤὡὪὪὡὨὧ",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
if (falseTimeoutTelemetry == null)
|
||||
{
|
||||
throw new MissingMethodException("IntroUI maintenance timeout telemetry method was not found");
|
||||
}
|
||||
harmony.Patch(
|
||||
falseTimeoutTelemetry,
|
||||
prefix: new HarmonyMethod(typeof(Plugin), nameof(SkipLocalTimeoutTelemetry)));
|
||||
|
||||
InstallLocalPurchaseBypass(harmony);
|
||||
|
||||
InstallDatabaseDiagnostics(harmony);
|
||||
Logger.LogInfo("Local identity active: AppManager.UseSdk => false");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Local identity patch failed: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool UseSdkPrefix(ref bool __result)
|
||||
{
|
||||
__result = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void SendMaintenancePrefix()
|
||||
{
|
||||
Type serverURLInfo = FindType("ὫὢὮὢὣὥὯὪὡὦὯ");
|
||||
FieldInfo maintenanceUri = serverURLInfo?.GetField(
|
||||
"ὢὭὪὨὣὮὧὦὠὮὦ",
|
||||
BindingFlags.Static | BindingFlags.Public);
|
||||
if (maintenanceUri == null)
|
||||
{
|
||||
throw new MissingFieldException("BDNetwork.ServerURLInfo.MaintenanceUri was not found");
|
||||
}
|
||||
maintenanceUri.SetValue(null, new Uri(LocalServerURL));
|
||||
Log?.LogInfo("MaintenanceUri => " + LocalServerURL);
|
||||
}
|
||||
|
||||
private static void OnFinishMaintenancePostfix(object __instance)
|
||||
{
|
||||
// 2.34.13's CancelMaintenanceTimeout cancels the active CTS and then
|
||||
// immediately stores a fresh CTS. Depending on async scheduling, the
|
||||
// timeout task can capture that fresh token after the successful
|
||||
// response and emit a false 10-second timeout. Cancel the replacement
|
||||
// token only after OnFinishMaintenanceRequest has completed.
|
||||
FieldInfo timeout = __instance.GetType().GetField(
|
||||
"ὬὭὥὪὢὭὫὩὭὭὬ",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
CancellationTokenSource source = timeout?.GetValue(__instance) as CancellationTokenSource;
|
||||
source?.Cancel();
|
||||
Log?.LogInfo("Maintenance timeout guard cancelled after successful response");
|
||||
}
|
||||
|
||||
private static bool SkipLocalTimeoutTelemetry()
|
||||
{
|
||||
// This method only emits intro_server_info_timeout telemetry after ten
|
||||
// seconds. Network request failures have their own callbacks. In
|
||||
// 2.34.13 it can outlive a successful local maintenance response.
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void InstallLocalPurchaseBypass(Harmony harmony)
|
||||
{
|
||||
Type platformRuler = FindType("ὮὮὫὭὢὩὭὢὦὪὠ");
|
||||
MethodInfo getProducts = platformRuler?
|
||||
.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
|
||||
.FirstOrDefault(method =>
|
||||
method.Name == "ὠὨὯὣὧὨὭὦὥὪὠ" &&
|
||||
method.GetParameters().Length == 6);
|
||||
if (getProducts == null)
|
||||
{
|
||||
throw new MissingMethodException("PlatformRuler.GetProductAsync was not found");
|
||||
}
|
||||
harmony.Patch(
|
||||
getProducts,
|
||||
prefix: new HarmonyMethod(typeof(Plugin), nameof(GetProductsPrefix)));
|
||||
|
||||
Type platformManager = FindType("gamfs.Platform.PlatformManager");
|
||||
MethodInfo purchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
|
||||
.FirstOrDefault(method => method.Name == "Purchase" && method.GetParameters().Length == 3);
|
||||
MethodInfo finishPurchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
|
||||
.FirstOrDefault(method => method.Name == "FinishPurchase" && method.GetParameters().Length == 2);
|
||||
if (purchase == null || finishPurchase == null)
|
||||
throw new MissingMethodException("PlatformManager purchase methods were not found");
|
||||
harmony.Patch(purchase, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(LocalPurchasePrefix)));
|
||||
harmony.Patch(finishPurchase, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(FinishLocalPurchasePrefix)));
|
||||
Log?.LogInfo("Local purchase price lookup disabled");
|
||||
Log?.LogInfo("Infinite reroll confirmation is local/free; all other paid purchases are blocked");
|
||||
}
|
||||
|
||||
private static bool GetProductsPrefix(Action __3)
|
||||
{
|
||||
// A private server has no Neon/GPG commerce identity. Treat price
|
||||
// prefetch as complete so startup can continue without contacting the
|
||||
// production payment API. No purchase result or currency is forged.
|
||||
__3?.Invoke();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool LocalPurchasePrefix(string __0, object __1, Action __2)
|
||||
{
|
||||
// Product 9100033 is the 2.34.13 infinite-reroll confirmation. The
|
||||
// local server grants the last preview through CashShopBuy without
|
||||
// contacting Neon/GPG. No other real-money product is authorized.
|
||||
if (__0 == "brd2_limited_pack_660" || __0 == "brd2_limited_pack_660_ios")
|
||||
{
|
||||
Type purchaseData = FindType("ὥὮὯὪὯὫὯὤὩὬὤ");
|
||||
object result = Activator.CreateInstance(purchaseData, new object[]
|
||||
{
|
||||
__0,
|
||||
1L,
|
||||
"bd2-local-free-infinite",
|
||||
"bd2-local-free-receipt"
|
||||
});
|
||||
Log?.LogInfo("Approved local/free infinite-reroll confirmation");
|
||||
(__1 as Delegate)?.DynamicInvoke(result);
|
||||
return false;
|
||||
}
|
||||
Log?.LogWarning("Blocked unsupported paid product: " + (__0 ?? "<null>"));
|
||||
__2?.Invoke();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool FinishLocalPurchasePrefix(string __0)
|
||||
{
|
||||
if (__0 == "bd2-local-free-infinite")
|
||||
{
|
||||
Log?.LogInfo("Finished local/free infinite-reroll confirmation");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void InstallDatabaseDiagnostics(Harmony harmony)
|
||||
{
|
||||
Type rawDataManager = FindType("RawDataManager");
|
||||
MethodInfo dbLoad = rawDataManager?.GetMethod(
|
||||
"DBLoad",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
if (dbLoad == null)
|
||||
{
|
||||
throw new MissingMethodException("RawDataManager.DBLoad was not found");
|
||||
}
|
||||
|
||||
harmony.Patch(
|
||||
dbLoad,
|
||||
prefix: new HarmonyMethod(typeof(Plugin), nameof(DBLoadPrefix)));
|
||||
|
||||
Type clientLocalInfo = FindType("Proto.Local.ClientLocalInfo");
|
||||
MethodInfo loadDB = clientLocalInfo?.GetMethod(
|
||||
"LoadDB",
|
||||
BindingFlags.Static | BindingFlags.Public);
|
||||
if (loadDB == null)
|
||||
{
|
||||
throw new MissingMethodException("ClientLocalInfo.LoadDB was not found");
|
||||
}
|
||||
|
||||
harmony.Patch(
|
||||
loadDB,
|
||||
prefix: new HarmonyMethod(typeof(Plugin), nameof(ClientLocalLoadPrefix)),
|
||||
postfix: new HarmonyMethod(typeof(Plugin), nameof(ClientLocalLoadPostfix)),
|
||||
finalizer: new HarmonyMethod(typeof(Plugin), nameof(ClientLocalLoadFinalizer)));
|
||||
|
||||
Log?.LogInfo("Database diagnostics active");
|
||||
}
|
||||
|
||||
private static void DBLoadPrefix(object __0, ref Action __1)
|
||||
{
|
||||
string dbName = ReadFirstStringField(__0) ?? "<unknown>";
|
||||
Action original = __1;
|
||||
Stopwatch elapsed = Stopwatch.StartNew();
|
||||
Log?.LogInfo("DBLoad start: " + dbName);
|
||||
__1 = delegate
|
||||
{
|
||||
Log?.LogInfo("DBLoad callback enter: " + dbName + " (" + elapsed.ElapsedMilliseconds + " ms)");
|
||||
try
|
||||
{
|
||||
original?.Invoke();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log?.LogInfo("DBLoad callback exit: " + dbName + " (" + elapsed.ElapsedMilliseconds + " ms)");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void ClientLocalLoadPrefix(out Stopwatch __state)
|
||||
{
|
||||
__state = Stopwatch.StartNew();
|
||||
Log?.LogInfo("ClientLocalInfo.LoadDB enter");
|
||||
}
|
||||
|
||||
private static void ClientLocalLoadPostfix(Stopwatch __state)
|
||||
{
|
||||
Log?.LogInfo("ClientLocalInfo.LoadDB exit (" + __state.ElapsedMilliseconds + " ms)");
|
||||
}
|
||||
|
||||
private static Exception ClientLocalLoadFinalizer(Exception __exception, Stopwatch __state)
|
||||
{
|
||||
if (__exception != null)
|
||||
{
|
||||
Log?.LogError(
|
||||
"ClientLocalInfo.LoadDB threw after " + __state.ElapsedMilliseconds + " ms: " + __exception);
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
private static string ReadFirstStringField(object value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
FieldInfo field = value.GetType()
|
||||
.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
|
||||
.FirstOrDefault(candidate => candidate.FieldType == typeof(string));
|
||||
return field?.GetValue(value) as string;
|
||||
}
|
||||
|
||||
private static Type FindType(string name)
|
||||
{
|
||||
Assembly assembly = Assembly.Load("Assembly-CSharp");
|
||||
Type type = assembly?.GetType(name);
|
||||
if (type != null)
|
||||
{
|
||||
return type;
|
||||
}
|
||||
foreach (Assembly loaded in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
type = loaded.GetType(name);
|
||||
if (type != null)
|
||||
{
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# 客户端插件工程
|
||||
|
||||
- `LocalIdentity/`:连接本地服务器的客户端使用;构建产物为 `BD2LocalIdentity.dll`。
|
||||
- `CaptureEnvironment/`:独立原版对照客户端抓包使用;构建产物为 `BD2CaptureEnvironment.dll`。
|
||||
|
||||
两个插件用于不同客户端环境。原版抓包环境不得安装 `BD2LocalIdentity.dll`。
|
||||
|
||||
构建时必须显式传入对应客户端目录:
|
||||
|
||||
```powershell
|
||||
dotnet build .\plugins\LocalIdentity\LocalIdentity.csproj -c Release -p:GameDir="<本地服客户端目录>"
|
||||
dotnet build .\plugins\CaptureEnvironment\CaptureEnvironment.csproj -c Release -p:GameDir="<原版客户端目录>"
|
||||
```
|
||||
|
||||
启动原版抓包环境:
|
||||
|
||||
```powershell
|
||||
.\tools\python\start-official-capture.ps1 `
|
||||
-GameDir "<原版客户端目录>" `
|
||||
-LocalClientDir "<本地服客户端目录>"
|
||||
```
|
||||
|
||||
用户数据共享目录默认从当前 Windows 用户目录推导;特殊环境可另外传入 `-SharedDataDir`。
|
||||
Reference in New Issue
Block a user