This commit is contained in:
2026-09-21 09:53:46 +08:00
commit 98d84a65f0
149 changed files with 34669 additions and 0 deletions
@@ -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=&lt;official-client-directory&gt;." />
</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>
+398
View File
@@ -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));
}
}
+40
View File
@@ -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=&lt;local-client-directory&gt;." />
</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>
+318
View File
@@ -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;
}
}
+23
View File
@@ -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`。