|
|
|
@@ -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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|