feat: migrate state and complete live systems

- replace the Haskell bridge with transactional SQLite state and validation

- centralize versions and harden resource, capture, and release tooling

- complete active gacha schedules, point exchange, and mail grants

- add equipment refinement, presets, and ordinary formation workflows

- improve client plugins, request timing, and normalized persistence
This commit is contained in:
2026-09-24 22:43:03 +08:00
parent 113e941bef
commit f94cfa1cf3
162 changed files with 13714 additions and 12632 deletions
@@ -5,6 +5,7 @@
<Nullable>disable</Nullable>
<AssemblyName>BD2CaptureEnvironment</AssemblyName>
<RootNamespace>Bd2CaptureEnvironment</RootNamespace>
<BD2PluginVersionKey>capture_environment</BD2PluginVersionKey>
</PropertyGroup>
<Target Name="RequireGameDir" BeforeTargets="ResolveReferences" Condition="'$(GameDir)' == ''">
<Error Text="GameDir is required. Build with -p:GameDir=&lt;official-client-directory&gt;." />
@@ -35,4 +36,5 @@
<Private>false</Private>
</Reference>
</ItemGroup>
<Import Project="..\VersionSource.targets" />
</Project>
+356 -177
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -10,7 +11,6 @@ using BepInEx.Logging;
using Google.Protobuf;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Networking;
namespace Bd2CaptureEnvironment;
@@ -19,18 +19,30 @@ 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";
public const string Version = Bd2Build.Versions.Plugin;
private const int MaxBodyBytes = 16 * 1024 * 1024;
private const string PlayerPrefsPrefix = "BD2OfficialCapture23413:";
private static readonly object FileLock = new object();
private const int MaxQueuedRecords = 256;
private const int WriterShutdownSeconds = 30;
private const string PlayerPrefsPrefix = "BD2OfficialCapture:" + Bd2Build.Versions.Client + ":";
private static readonly object CorrelationLock = new object();
private static readonly object FailureFileLock = new object();
private static readonly Dictionary<string, Queue<PendingRequest>> PendingRequests =
new Dictionary<string, Queue<PendingRequest>>(StringComparer.Ordinal);
private static readonly BlockingCollection<CaptureRecord> WriteQueue =
new BlockingCollection<CaptureRecord>(
new ConcurrentQueue<CaptureRecord>(), MaxQueuedRecords);
private static ManualLogSource Log;
private static string CaptureDirectory;
private static string JsonlPath;
private static string ReadableLogPath;
private static string IsolatedDataDirectory;
private static string SharedGameDataDirectory;
private static string IsolatedGameDataDirectory;
private static long Sequence;
private static long LastApiRequest;
private static Thread WriterThread;
private static int WriterFailed;
private static int RejectionLogged;
private static int StopRequested;
private void Awake()
{
@@ -39,15 +51,14 @@ public sealed class Plugin : BaseUnityPlugin
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);
IsolatedGameDataDirectory = Path.Combine(IsolatedDataDirectory, "Data", "t");
CaptureDirectory = Path.GetFullPath(Path.Combine(
root, "Capture", DateTime.Now.ToString("yyyyMMdd-HHmmss")));
JsonlPath = Path.Combine(CaptureDirectory, "capture.jsonl");
Directory.CreateDirectory(IsolatedDataDirectory);
ReadableLogPath = Path.Combine(CaptureDirectory, "capture.log");
EnsurePrivateGameDataDirectory();
Directory.CreateDirectory(Path.Combine(CaptureDirectory, "bodies"));
StartWriter();
var harmony = new Harmony(Guid);
InstallStorageIsolation(harmony);
@@ -58,30 +69,57 @@ public sealed class Plugin : BaseUnityPlugin
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("isolated GameData => " + IsolatedGameDataDirectory);
Logger.LogInfo("capture => " + CaptureDirectory);
}
catch (Exception ex)
{
Logger.LogError("Capture environment setup failed: " + ex);
MarkCaptureIncomplete("capture environment setup failed", ex);
}
}
private static string ReadRequiredArgument(string name)
private void OnApplicationQuit()
{
string[] arguments = Environment.GetCommandLineArgs();
for (int i = 0; i + 1 < arguments.Length; i++)
StopWriter();
}
private sealed class PendingRequest
{
public long Id;
public string Type;
}
private sealed class CaptureRecord
{
public DateTime Timestamp;
public long Id;
public string Direction;
public string Path;
public string Type;
public byte[] Body;
public int Length;
public string Note;
}
private static void EnsurePrivateGameDataDirectory()
{
foreach (string path in new[]
{
if (string.Equals(arguments[i], name, StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(arguments[i + 1]))
return Path.GetFullPath(arguments[i + 1]);
IsolatedDataDirectory,
Path.Combine(IsolatedDataDirectory, "Data"),
IsolatedGameDataDirectory
})
{
if (Directory.Exists(path) &&
(new DirectoryInfo(path).Attributes & FileAttributes.ReparsePoint) != 0)
throw new InvalidOperationException("Isolated GameData path is linked: " + path);
Directory.CreateDirectory(path);
}
throw new ArgumentException("Required command-line argument is missing: " + name);
}
private static void InstallStorageIsolation(Harmony harmony)
@@ -136,109 +174,20 @@ public sealed class Plugin : BaseUnityPlugin
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)
BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null);
if (gameDataPath == null || gameDataPath.ReturnType != typeof(string))
throw new MissingMethodException("NetworkManager.GetPachedGameDataPath not found");
harmony.Patch(gameDataPath, prefix: new HarmonyMethod(
typeof(Plugin), nameof(GameDataPathPrefix)));
Log?.LogInfo("Shared GameData path hook installed");
Log?.LogInfo("Private installed GameData path hook installed");
MethodInfo send = manager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.FirstOrDefault(method => method.Name == "Send" &&
method.ReturnType == typeof(void) &&
method.GetParameters().Length == 6 &&
typeof(IMessage).IsAssignableFrom(method.GetParameters()[0].ParameterType));
if (send != null)
@@ -252,23 +201,32 @@ public sealed class Plugin : BaseUnityPlugin
Log?.LogWarning("NetworkManager.Send plaintext hook not found");
}
MethodInfo decrypt = manager?.GetMethod("AESDecrypt256",
BindingFlags.Instance | BindingFlags.Public);
if (decrypt != null)
Type responseData = FindType("BDNetwork.ResponseData");
MethodInfo responseCheck = manager?.GetMethods(
BindingFlags.Instance | BindingFlags.NonPublic)
.SingleOrDefault(method =>
{
ParameterInfo[] parameters = method.GetParameters();
return method.ReturnType == typeof(bool) && parameters.Length == 4 &&
parameters[0].ParameterType == typeof(string) &&
parameters[2].ParameterType == responseData &&
parameters[3].ParameterType == typeof(byte[]);
});
if (responseCheck != null)
{
harmony.Patch(decrypt, postfix: new HarmonyMethod(
typeof(Plugin), nameof(DecryptPostfix)));
Log?.LogInfo("Plaintext response capture installed");
harmony.Patch(responseCheck, prefix: new HarmonyMethod(
typeof(Plugin), nameof(ResponseCheckPrefix)));
Log?.LogInfo("Path-correlated plaintext response capture installed");
}
else
{
Log?.LogWarning("NetworkManager.AESDecrypt256 hook not found");
Log?.LogWarning("NetworkManager.ResponseCheck plaintext hook not found");
}
}
private static bool GameDataPathPrefix(ref string __result)
{
__result = SharedGameDataDirectory;
__result = IsolatedGameDataDirectory;
return false;
}
@@ -278,10 +236,11 @@ public sealed class Plugin : BaseUnityPlugin
{
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);
string path = RequestPath(__0.GetType().Name);
long id = Interlocked.Increment(ref Sequence);
EnqueueRequest(path, new PendingRequest { Id = id, Type = type });
QueueCapture(id, "request", path, type, body);
}
catch (Exception ex)
{
@@ -289,78 +248,274 @@ public sealed class Plugin : BaseUnityPlugin
}
}
private static void DecryptPostfix(string __result)
private static void ResponseCheckPrefix(string __0, byte[] __3)
{
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);
string path = NormalizePath(__0);
PendingRequest request = DequeueRequest(path);
long id = request?.Id ?? Interlocked.Increment(ref Sequence);
string type = ResponseType(request?.Type, path);
QueueCapture(id, "response", path, type, __3);
}
catch (FormatException) { }
catch (Exception ex)
{
Log?.LogWarning("plaintext response capture failed: " + ex.Message);
}
}
private static bool IsGameApi(Uri uri, string method)
private static string RequestPath(string typeName)
{
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);
const string suffix = "Request";
if (typeName != null && typeName.EndsWith(suffix, StringComparison.Ordinal))
typeName = typeName.Substring(0, typeName.Length - suffix.Length);
return NormalizePath(typeName);
}
private static bool TrySafeUri(string value, out Uri uri)
private static string NormalizePath(string path)
{
return Uri.TryCreate(value, UriKind.Absolute, out uri);
if (string.IsNullOrWhiteSpace(path)) return "/unknown";
return path[0] == '/' ? path : "/" + path;
}
private static string SaveBody(long id, string phase, string path, byte[] body)
private static string ResponseType(string requestType, string path)
{
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)
const string suffix = "Request";
if (!string.IsNullOrEmpty(requestType) &&
requestType.EndsWith(suffix, StringComparison.Ordinal))
return requestType.Substring(0, requestType.Length - suffix.Length) + "Response";
return "Proto.Net." + path.TrimStart('/') + "Response";
}
private static void EnqueueRequest(string path, PendingRequest request)
{
lock (CorrelationLock)
{
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);
if (!PendingRequests.TryGetValue(path, out Queue<PendingRequest> requests))
{
requests = new Queue<PendingRequest>();
PendingRequests.Add(path, requests);
}
requests.Enqueue(request);
}
}
private static PendingRequest DequeueRequest(string path)
{
lock (CorrelationLock)
{
if (PendingRequests.TryGetValue(path, out Queue<PendingRequest> requests) &&
requests.Count != 0)
{
PendingRequest request = requests.Dequeue();
if (requests.Count == 0) PendingRequests.Remove(path);
return request;
}
}
return null;
}
private static void QueueCapture(long id, string direction, string path,
string type, byte[] body)
{
if (Volatile.Read(ref WriterFailed) != 0)
{
LogRejectedPacket(id, direction, path,
"capture writer is unavailable");
return;
}
if (WriteQueue.IsAddingCompleted)
{
string closedReason = "capture queue was already closed when packet " +
id + " " + direction + " " + path + " arrived";
MarkCaptureIncomplete(closedReason, null);
LogRejectedPacket(id, direction, path, closedReason);
return;
}
int length = body?.Length ?? 0;
string note = body == null ? "protobuf body is null" : null;
byte[] owned = null;
if (length <= MaxBodyBytes)
{
if (length != 0)
{
owned = new byte[length];
Buffer.BlockCopy(body, 0, owned, 0, length);
}
}
else
{
note = "body exceeds " + MaxBodyBytes + " byte capture limit";
}
var record = new CaptureRecord
{
Timestamp = DateTime.Now,
Id = id,
Direction = direction,
Path = path,
Type = type,
Body = owned,
Length = length,
Note = note
};
try
{
if (WriteQueue.TryAdd(record)) return;
}
catch (InvalidOperationException)
{
// CompleteAdding can race a producer during application shutdown.
}
string reason = WriteQueue.IsAddingCompleted
? "capture queue closed before packet " + id + " " + direction +
" " + path + " could be queued"
: "capture queue capacity " + MaxQueuedRecords +
" was exceeded; packet " + id + " " + direction + " " + path +
" was not captured";
MarkCaptureIncomplete(reason, null);
LogRejectedPacket(id, direction, path, reason);
}
private static void StartWriter()
{
WriterThread = new Thread(WriterLoop)
{
IsBackground = true,
Name = "BD2 capture writer"
};
WriterThread.Start();
}
private static void StopWriter()
{
if (Interlocked.Exchange(ref StopRequested, 1) != 0) return;
CompleteWriterQueue();
if (WriterThread == null || !WriterThread.IsAlive) return;
if (WriterThread.Join(TimeSpan.FromSeconds(WriterShutdownSeconds))) return;
MarkCaptureIncomplete(
"capture writer did not drain " + WriteQueue.Count +
" queued records within " + WriterShutdownSeconds +
" seconds during shutdown", null);
}
private static void WriterLoop()
{
try
{
using var json = new StreamWriter(JsonlPath, false, new UTF8Encoding(false));
using var readable = new StreamWriter(
ReadableLogPath, false, new UTF8Encoding(false));
readable.WriteLine("timestamp id dir path protobuf type bytes body");
readable.WriteLine(new string('-', 160));
foreach (CaptureRecord record in WriteQueue.GetConsumingEnumerable())
{
try
{
string bodyFile = WriteBody(record);
json.WriteLine(RecordJson(record, bodyFile));
readable.WriteLine(ReadableLine(record, bodyFile));
json.Flush();
readable.Flush();
}
catch (Exception ex)
{
MarkCaptureIncomplete(
"capture writer failed while writing packet " + record.Id +
" " + record.Direction + " " + record.Path, ex);
return;
}
}
}
catch (Exception ex)
{
MarkCaptureIncomplete("capture writer failed to initialize or finalize", ex);
}
}
private static void CompleteWriterQueue()
{
if (WriteQueue.IsAddingCompleted) return;
try { WriteQueue.CompleteAdding(); }
catch (InvalidOperationException) { }
}
private static void MarkCaptureIncomplete(string reason, Exception error)
{
if (Interlocked.CompareExchange(ref WriterFailed, 1, 0) != 0) return;
CompleteWriterQueue();
string detail = DateTime.Now.ToString("O") + Environment.NewLine + reason;
if (error != null) detail += Environment.NewLine + error;
detail += Environment.NewLine;
try
{
if (!string.IsNullOrEmpty(CaptureDirectory))
{
Directory.CreateDirectory(CaptureDirectory);
lock (FailureFileLock)
{
File.WriteAllText(
Path.Combine(CaptureDirectory, "INCOMPLETE.txt"),
detail, new UTF8Encoding(false));
}
}
}
catch (Exception markerError)
{
Log?.LogError("could not write capture INCOMPLETE marker: " + markerError);
}
Log?.LogError("CAPTURE IS INCOMPLETE: " + reason +
(error == null ? "" : Environment.NewLine + error));
}
private static void LogRejectedPacket(long id, string direction, string path,
string reason)
{
if (Interlocked.Exchange(ref RejectionLogged, 1) != 0) return;
Log?.LogError("CAPTURE PACKETS ARE BEING REJECTED: packet " + id + " " +
direction + " " + path + "; " + reason);
}
private static string WriteBody(CaptureRecord record)
{
if (record.Body == null || record.Body.Length == 0) return null;
string type = record.Type ?? record.Path ?? "protobuf";
int dot = type.LastIndexOf('.');
if (dot >= 0) type = type.Substring(dot + 1);
string safe = new string(type.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_')
.ToArray()).Trim('_');
if (safe.Length == 0) safe = "protobuf";
string name = record.Id.ToString("D6") + "_" + record.Direction +
"_" + safe + ".pb";
File.WriteAllBytes(Path.Combine(CaptureDirectory, "bodies", name), record.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)
private static string RecordJson(CaptureRecord record, string bodyFile)
{
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));
return "{" +
"\"timestamp\":\"" + Escape(record.Timestamp.ToString("O")) + "\"," +
"\"id\":" + record.Id + "," +
"\"direction\":\"" + Escape(record.Direction) + "\"," +
"\"path\":\"" + Escape(record.Path) + "\"," +
"\"protobuf_type\":\"" + Escape(record.Type) + "\"," +
"\"length\":" + record.Length + "," +
"\"body\":" + JsonString(bodyFile) + "," +
"\"note\":" + JsonString(record.Note) + "}";
}
private static string ReadableLine(CaptureRecord record, string bodyFile)
{
string direction = record.Direction == "request" ? "REQ" : "RESP";
return string.Format("{0,-33} {1,6:D6} {2,-4} {3,-40} {4,-50} {5,8} {6}",
record.Timestamp.ToString("O"), record.Id, direction,
Truncate(record.Path, 40), Truncate(record.Type, 50), record.Length,
bodyFile ?? record.Note ?? "-");
}
private static string Truncate(string value, int width)
{
value ??= "";
return value.Length <= width ? value : value.Substring(0, width - 1) + "…";
}
private static string JsonString(string value)
@@ -371,8 +526,27 @@ public sealed class Plugin : BaseUnityPlugin
private static string Escape(string value)
{
if (value == null) return "";
return value.Replace("\\", "\\\\").Replace("\"", "\\\"")
.Replace("\r", "\\r").Replace("\n", "\\n");
var escaped = new StringBuilder(value.Length + 16);
foreach (char character in value)
{
switch (character)
{
case '\"': escaped.Append("\\\""); break;
case '\\': escaped.Append("\\\\"); break;
case '\b': escaped.Append("\\b"); break;
case '\f': escaped.Append("\\f"); break;
case '\n': escaped.Append("\\n"); break;
case '\r': escaped.Append("\\r"); break;
case '\t': escaped.Append("\\t"); break;
default:
if (character <= '\u001f')
escaped.Append("\\u").Append(((int)character).ToString("x4"));
else
escaped.Append(character);
break;
}
}
return escaped.ToString();
}
private static Type FindType(string fullName)
@@ -389,10 +563,15 @@ public sealed class Plugin : BaseUnityPlugin
private static void WriteMetadata()
{
File.WriteAllText(Path.Combine(CaptureDirectory, "README.txt"),
"BD2 2.34.13 official API capture.\r\n" +
"BD2 " + Bd2Build.Versions.Client + " official API capture.\r\n" +
"All NetworkManager plaintext protobuf requests and responses are recorded.\r\n" +
"capture.jsonl is machine-readable; capture.log is the aligned human-readable index.\r\n" +
"If INCOMPLETE.txt exists, the writer rejected or could not flush part of the 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",
"clientVersion=" + Application.version + "\r\n" +
"persistentDataPath=" + IsolatedDataDirectory + "\r\n" +
"gameDataPath=" + IsolatedGameDataDirectory + "\r\n",
new UTF8Encoding(false));
}
}
+29 -13
View File
@@ -1,21 +1,34 @@
# BD2 official capture environment plugin
This BepInEx plugin is only for the separately extracted official 2.34.13 client.
This BepInEx plugin is only for the separately extracted official client selected by the repository-root `versions.json`.
- 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;
- prefixes game `PlayerPrefs` keys with a namespace derived from the configured client version;
- keeps account/profile/Neo/Intro and the installed GameData working set in
`<game>/IsolatedUserData`; `NetworkManager.GetPachedGameDataPath()` uses the
private `Data/t` directory and refuses a linked GameData path;
- uses `PluginLocalRes.dll` to read the explicitly supplied, reviewed
ServerData/GameData source; SoundData and VideoData are shared static-data
junctions, while the versioned Addressables catalog cache remains private;
player saves and account data are never linked or copied from the primary
client;
- uses the official client archive's own `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;
- writes through a bounded asynchronous queue; queue overflow or any writer
failure creates `INCOMPLETE.txt`, rejects later packets with an error in the
BepInEx log, and prevents a partial capture from looking complete;
- drains the writer queue during normal application shutdown; if the drain
cannot finish within the shutdown safety deadline, the capture is marked
incomplete instead of silently dropping the tail;
- 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.
Request and response protobuf records share one correlation ID when the client
provides the request path. Batch responses are captured after each item is
decrypted. JSONL retains the complete path and type; the human-readable log
truncates those two display columns only. Treat a capture containing
`INCOMPLETE.txt` as unusable evidence and repeat it.
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
@@ -24,17 +37,20 @@ 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.
explicit isolated `-logFile` path and validates static-data junctions. The
launcher requires the official-client directory argument.
Pass them explicitly, for example:
```powershell
.\tools\python\start-official-capture.ps1 `
-GameDir "<official-client-directory>" `
-LocalClientDir "<local-client-directory>"
-ResourceRoot "<downloaded-resource-root>"
```
The launcher reads bundle and GameData versions from the repository-root
`versions.json`. `-VersionConfig`, `-BundleVersion`, and `-GameDataVersion`
remain explicit development overrides.
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,25 @@
[General]
## Redirect the official comparison client to the already downloaded local resources.
# Setting type: Boolean
# Default value: true
Enabled = true
## ServerData root. The selected version must exist below
## StandaloneWindows64/HD/<BundleVersion>/.
# Setting type: String
LocalCDN = <RESOURCE_ROOT>\ServerData
## GameData root. The selected version must contain
## <GameDataVersion>/release/common-dbdata.bin.
# Setting type: String
LocalGameData = <RESOURCE_ROOT>\GameData
# Setting type: String
BundleVersion = <BUNDLE_VERSION>
# Setting type: String
GameDataVersion = <GAME_DATA_VERSION>
# Setting type: String
Resolution = HD