Loopback MCP host with observe/control/develop gates and a hot-reload tools DLL. Default token is local-dev only. Leave the module off unless you are driving S3 from an agent.
1029 lines
42 KiB
C#
1029 lines
42 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using Helpers;
|
|
using Model;
|
|
using Newtonsoft.Json.Linq;
|
|
using S3.Mcp;
|
|
using S3.Modules.BaseGamePerf;
|
|
using S3.Modules.Profiler;
|
|
using UnityEngine;
|
|
using UnityEngine.Profiling;
|
|
|
|
namespace S3.Mcp.Tools;
|
|
|
|
public sealed class ToolsPlugin : IAgentPlugin
|
|
{
|
|
public string Id => "s3.mcp.tools";
|
|
|
|
IMcpApi? _api;
|
|
|
|
public void Start(IMcpApi api)
|
|
{
|
|
_api = api;
|
|
api.Log("tools pack " + typeof(ToolsPlugin).Assembly.GetName().Name);
|
|
|
|
api.RegisterTool("hover",
|
|
"Mouse raycast from the game camera: colliders, layers, scenery ids, shaders, URP material props. Point at what you care about first.",
|
|
Schema("maxHits", "integer"),
|
|
McpGate.Observe, Hover);
|
|
|
|
api.RegisterTool("inspect",
|
|
"Reflection dump. target=selected (selected car), hover (first ray hit), or type (typeName = Assembly-CSharp type).",
|
|
Schema(
|
|
("target", "string"),
|
|
("typeName", "string"),
|
|
("maxDepth", "integer")),
|
|
McpGate.Observe, Inspect);
|
|
|
|
api.RegisterTool("screenshot",
|
|
"Capture the main camera to Mods/S3/mcp-shot.png and return the image.",
|
|
Schema("maxWidth", "integer"),
|
|
McpGate.Observe, Screenshot);
|
|
|
|
api.RegisterTool("dump",
|
|
"Run an S3 dump command. which=industry|wq|physics. Optional filter for industry.",
|
|
Schema(("which", "string"), ("filter", "string")),
|
|
McpGate.Observe, Dump);
|
|
|
|
api.RegisterTool("consists",
|
|
"List IntegrationSets / consists currently in the session.",
|
|
Schema("limit", "integer"),
|
|
McpGate.Observe, args =>
|
|
McpToolResult.Ok(api.Game.ConsistsText(args["limit"]?.Value<int?>() ?? 40)));
|
|
|
|
api.RegisterTool("industries",
|
|
"Industry dump, optional name filter. Same data as /s3ind dump.",
|
|
Schema("filter", "string"),
|
|
McpGate.Observe, args =>
|
|
{
|
|
string filter = args["filter"]?.Value<string>() ?? "";
|
|
string cmd = string.IsNullOrEmpty(filter) ? "/s3ind dump" : "/s3ind dump " + filter;
|
|
return McpToolResult.Ok(api.Game.RunSlash(cmd));
|
|
});
|
|
|
|
api.RegisterTool("benchmark",
|
|
"Start, query, or cancel a four-pass stationary/motion scenario benchmark. " +
|
|
"Module lists are comma-separated ids. All settings and live module states are restored.",
|
|
Schema(
|
|
("action", "string"),
|
|
("secondsPerPass", "number"),
|
|
("label", "string"),
|
|
("motionMode", "string"),
|
|
("disabledModules", "string"),
|
|
("baselineDisabledModules", "string"),
|
|
("scenarioDisabledModules", "string"),
|
|
("captureHitchProbes", "boolean"),
|
|
("hitchThresholdMs", "number"),
|
|
("captureUnityBinaryLog", "boolean"),
|
|
("cameraX", "number"),
|
|
("cameraY", "number"),
|
|
("cameraZ", "number"),
|
|
("cameraPitch", "number"),
|
|
("cameraYaw", "number"),
|
|
("cameraFov", "number")),
|
|
McpGate.Control, args =>
|
|
{
|
|
string action = args["action"]?.Value<string>() ?? "status";
|
|
if (action.Equals("status", StringComparison.OrdinalIgnoreCase)
|
|
|| action.Equals("cancel", StringComparison.OrdinalIgnoreCase))
|
|
return McpToolResult.Ok(
|
|
AutomatedBenchmark.Handle(new[] { "/s3bench", action }));
|
|
if (!action.Equals("start", StringComparison.OrdinalIgnoreCase))
|
|
return McpToolResult.Fail("action must be start, status, or cancel");
|
|
var options = new BenchmarkOptions
|
|
{
|
|
SecondsPerPass = args["secondsPerPass"]?.Value<float?>() ?? 5f,
|
|
Label = args["label"]?.Value<string>() ?? "s3",
|
|
MotionMode = args["motionMode"]?.Value<string>() ?? "orbit",
|
|
DisabledModules = args["disabledModules"]?.Value<string>() ?? "",
|
|
BaselineDisabledModules =
|
|
args["baselineDisabledModules"]?.Value<string>() ?? "",
|
|
ScenarioDisabledModules =
|
|
args["scenarioDisabledModules"]?.Value<string>() ?? "",
|
|
CaptureHitchProbes = args["captureHitchProbes"]?.Value<bool?>(),
|
|
HitchThresholdMs = args["hitchThresholdMs"]?.Value<float?>(),
|
|
CaptureUnityBinaryLog =
|
|
args["captureUnityBinaryLog"]?.Value<bool?>(),
|
|
CameraX = args["cameraX"]?.Value<float?>(),
|
|
CameraY = args["cameraY"]?.Value<float?>(),
|
|
CameraZ = args["cameraZ"]?.Value<float?>(),
|
|
CameraPitch = args["cameraPitch"]?.Value<float?>(),
|
|
CameraYaw = args["cameraYaw"]?.Value<float?>(),
|
|
CameraFov = args["cameraFov"]?.Value<float?>(),
|
|
};
|
|
return McpToolResult.Ok(AutomatedBenchmark.Start(options));
|
|
});
|
|
|
|
api.RegisterTool("module_set",
|
|
"Enable or disable one S3 module live. The MCP and profiler modules are protected. " +
|
|
"Changes are session-only unless persist=true.",
|
|
Schema(
|
|
("id", "string"),
|
|
("active", "boolean"),
|
|
("persist", "boolean")),
|
|
McpGate.Control, args =>
|
|
{
|
|
string id = args["id"]?.Value<string>() ?? "";
|
|
bool active = args["active"]?.Value<bool?>() ?? true;
|
|
bool persist = args["persist"]?.Value<bool?>() ?? false;
|
|
return McpToolResult.Ok(AutomatedBenchmark.SetModule(id, active, persist));
|
|
});
|
|
|
|
api.RegisterTool("physics_freeze",
|
|
"Freeze or unfreeze the selected consist's IntegrationSet. Control gate.",
|
|
Schema("freeze", "boolean"),
|
|
McpGate.Control, args =>
|
|
{
|
|
bool freeze = args["freeze"]?.Value<bool?>() ?? true;
|
|
return McpToolResult.Ok(api.Game.FreezeSelected(freeze));
|
|
});
|
|
|
|
api.RegisterTool("select_car",
|
|
"Select a car by id. Control gate.",
|
|
Schema("id", "string"),
|
|
McpGate.Control, args =>
|
|
McpToolResult.Ok(api.Game.SelectCar(args["id"]?.Value<string>() ?? "")));
|
|
|
|
api.RegisterTool("reflect_type",
|
|
"Public members of a live type (Assembly-CSharp or S3). Develop gate.",
|
|
Schema("typeName", "string"),
|
|
McpGate.Develop, ReflectType);
|
|
|
|
api.RegisterTool("scene_find",
|
|
"Find Unity objects by name substring and optional component type name. Develop gate.",
|
|
Schema(("nameContains", "string"), ("component", "string"), ("limit", "integer")),
|
|
McpGate.Develop, SceneFind);
|
|
|
|
api.RegisterTool("render_stats",
|
|
"Count active renderers and approximate mesh load in the main-camera frustum, grouped by layer.",
|
|
Schema("maxDistanceFeet", "number"),
|
|
McpGate.Observe, RenderStats);
|
|
|
|
api.RegisterTool("camera_mode",
|
|
"Query or switch the live camera mode: FirstPerson, Strategy, or Dispatcher.",
|
|
Schema("mode", "string"),
|
|
McpGate.Control, CameraMode);
|
|
|
|
api.RegisterTool("hitch_capture",
|
|
"Start, query, or stop a manual per-frame hitch capture while the player moves the camera.",
|
|
Schema(
|
|
("action", "string"),
|
|
("label", "string"),
|
|
("hitchThresholdMs", "number"),
|
|
("deep", "boolean"),
|
|
("captureUnityBinaryLog", "boolean")),
|
|
McpGate.Control, HitchCapture);
|
|
|
|
api.RegisterTool("nature_renderers",
|
|
"Inspect loaded NatureRenderer instances and streaming settings, including inactive objects.",
|
|
Schema(),
|
|
McpGate.Observe, NatureRenderers);
|
|
|
|
api.RegisterTool("basegame_set",
|
|
"Tune Base Game Performance GC and Nature Renderer streaming controls live.",
|
|
Schema(
|
|
("gcSmoothing", "boolean"),
|
|
("incrementalSliceMs", "number"),
|
|
("natureStreamingSmoothing", "boolean"),
|
|
("grassInstanceBudget", "integer"),
|
|
("queueNearbyGrass", "boolean"),
|
|
("grassUnloadSpreadFrames", "integer"),
|
|
("distanceCullNatureTerrains", "boolean"),
|
|
("persist", "boolean")),
|
|
McpGate.Control, BaseGameSet);
|
|
}
|
|
|
|
public void Stop()
|
|
{
|
|
if (_manualCapture)
|
|
{
|
|
SparseHitchSampler.Cancel();
|
|
_manualCapture = false;
|
|
}
|
|
DeepMethodProfiler.Stop();
|
|
UnityMarkerProfiler.Stop();
|
|
PlayerLoopProfiler.Stop();
|
|
StopUnityProfiler();
|
|
_api = null;
|
|
}
|
|
|
|
McpToolResult Hover(JObject args)
|
|
{
|
|
int maxHits = args["maxHits"]?.Value<int?>() ?? 12;
|
|
if (maxHits < 1) maxHits = 1;
|
|
if (maxHits > 32) maxHits = 32;
|
|
|
|
Camera? cam = Camera.main;
|
|
try { MainCameraHelper.TryGetIfNeeded(ref cam); }
|
|
catch { }
|
|
if (cam == null)
|
|
return McpToolResult.Fail("no camera");
|
|
|
|
var sb = new StringBuilder();
|
|
Vector3 mouse = Input.mousePosition;
|
|
sb.AppendLine($"mouse=({mouse.x:0},{mouse.y:0}) cam={cam.name}");
|
|
Ray ray = cam.ScreenPointToRay(mouse);
|
|
var hits = Physics.RaycastAll(ray, 500f);
|
|
Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
|
|
sb.AppendLine($"hits={hits.Length}");
|
|
int n = Math.Min(hits.Length, maxHits);
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
var h = hits[i];
|
|
var col = h.collider;
|
|
string layer = col != null ? LayerMask.LayerToName(col.gameObject.layer) : "?";
|
|
var sc = col != null ? col.GetComponentInParent<SceneryAssetInstance>() : null;
|
|
string sid = sc != null ? (sc.identifier ?? sc.name) : "-";
|
|
sb.AppendLine($" {h.distance:0.00}m {col?.gameObject.name} layer={layer} scenery={sid}");
|
|
if (col == null) continue;
|
|
var rends = col.GetComponentsInParent<Renderer>();
|
|
int rn = Math.Min(rends.Length, 4);
|
|
for (int r = 0; r < rn; r++)
|
|
AppendRenderer(sb, rends[r], " ");
|
|
}
|
|
return McpToolResult.Ok(sb.ToString().TrimEnd());
|
|
}
|
|
|
|
static void AppendRenderer(StringBuilder sb, Renderer r, string pad)
|
|
{
|
|
if (r == null) return;
|
|
Vector3 s = r.bounds.size;
|
|
sb.Append(pad).Append(r.name)
|
|
.Append(" shader=").Append(r.sharedMaterial != null && r.sharedMaterial.shader != null
|
|
? r.sharedMaterial.shader.name : "?")
|
|
.Append($" size=({s.x:0.0},{s.y:0.0},{s.z:0.0})");
|
|
sb.AppendLine();
|
|
try
|
|
{
|
|
var mats = r.sharedMaterials;
|
|
if (mats == null) return;
|
|
int mn = Math.Min(mats.Length, 4);
|
|
for (int i = 0; i < mn; i++)
|
|
AppendMat(sb, mats[i], pad + " ");
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
static void AppendMat(StringBuilder sb, Material mat, string pad)
|
|
{
|
|
if (mat == null) { sb.Append(pad).AppendLine("mat=null"); return; }
|
|
sb.Append(pad).Append("mat=").Append(mat.name);
|
|
if (mat.shader != null) sb.Append(" shader=").Append(mat.shader.name);
|
|
AppendFloat(sb, mat, "_Surface");
|
|
AppendFloat(sb, mat, "_Mode");
|
|
AppendFloat(sb, mat, "_ZWrite");
|
|
AppendColor(sb, mat, "_BaseColor");
|
|
AppendColor(sb, mat, "_Color");
|
|
try
|
|
{
|
|
var keys = mat.shaderKeywords;
|
|
if (keys != null && keys.Length > 0)
|
|
sb.Append(" keywords=").Append(string.Join(",", keys));
|
|
}
|
|
catch { }
|
|
sb.AppendLine();
|
|
}
|
|
|
|
static void AppendFloat(StringBuilder sb, Material mat, string prop)
|
|
{
|
|
try
|
|
{
|
|
if (!mat.HasProperty(prop)) return;
|
|
sb.Append(' ').Append(prop).Append('=').Append(mat.GetFloat(prop).ToString("0.###"));
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
static void AppendColor(StringBuilder sb, Material mat, string prop)
|
|
{
|
|
try
|
|
{
|
|
if (!mat.HasProperty(prop)) return;
|
|
Color c = mat.GetColor(prop);
|
|
sb.Append(' ').Append(prop).Append('=')
|
|
.Append($"({c.r:0.00},{c.g:0.00},{c.b:0.00},{c.a:0.00})");
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
McpToolResult Inspect(JObject args)
|
|
{
|
|
string target = (args["target"]?.Value<string>() ?? "selected").ToLowerInvariant();
|
|
int depth = args["maxDepth"]?.Value<int?>() ?? 2;
|
|
if (depth < 1) depth = 1;
|
|
if (depth > 4) depth = 4;
|
|
|
|
object? obj = null;
|
|
if (target == "type")
|
|
{
|
|
string typeName = args["typeName"]?.Value<string>() ?? "";
|
|
var t = FindType(typeName);
|
|
if (t == null) return McpToolResult.Fail("type not found: " + typeName);
|
|
return McpToolResult.Ok(DumpType(t));
|
|
}
|
|
if (target == "hover")
|
|
{
|
|
Camera? cam = Camera.main;
|
|
try { MainCameraHelper.TryGetIfNeeded(ref cam); }
|
|
catch { }
|
|
if (cam == null) return McpToolResult.Fail("no camera");
|
|
if (!Physics.Raycast(cam.ScreenPointToRay(Input.mousePosition), out RaycastHit hit, 500f))
|
|
return McpToolResult.Fail("no hit");
|
|
obj = hit.collider != null ? hit.collider.gameObject : null;
|
|
}
|
|
else
|
|
{
|
|
try { obj = TrainController.Shared?.SelectedCar; }
|
|
catch { }
|
|
if (obj == null) return McpToolResult.Fail("no selected car");
|
|
}
|
|
|
|
var seen = new HashSet<int>();
|
|
return McpToolResult.Ok(DumpObject(obj, depth, 0, seen));
|
|
}
|
|
|
|
McpToolResult Screenshot(JObject args)
|
|
{
|
|
int maxWidth = args["maxWidth"]?.Value<int?>() ?? 1280;
|
|
string path = _api!.Game.Screenshot(maxWidth);
|
|
if (path.StartsWith("screenshot failed") || path == "no camera")
|
|
return McpToolResult.Fail(path);
|
|
return McpToolResult.Image(path, "wrote " + path);
|
|
}
|
|
|
|
McpToolResult Dump(JObject args)
|
|
{
|
|
string which = (args["which"]?.Value<string>() ?? "").ToLowerInvariant();
|
|
string filter = args["filter"]?.Value<string>() ?? "";
|
|
string cmd = which switch
|
|
{
|
|
"industry" or "ind" => string.IsNullOrEmpty(filter) ? "/s3ind dump" : "/s3ind dump " + filter,
|
|
"wq" => "/s3wq dump",
|
|
"physics" or "rpf" => "/rpf dump",
|
|
_ => "",
|
|
};
|
|
if (cmd.Length == 0)
|
|
return McpToolResult.Fail("which must be industry, wq, or physics");
|
|
return McpToolResult.Ok(_api!.Game.RunSlash(cmd));
|
|
}
|
|
|
|
static McpToolResult CameraMode(JObject args)
|
|
{
|
|
CameraSelector? selector = CameraSelector.shared;
|
|
if (selector == null) return McpToolResult.Fail("camera selector unavailable");
|
|
|
|
string requested = args["mode"]?.Value<string>() ?? "";
|
|
if (!string.IsNullOrWhiteSpace(requested))
|
|
{
|
|
if (!Enum.TryParse(
|
|
requested, true, out CameraSelector.CameraIdentifier identifier))
|
|
return McpToolResult.Fail("mode must be FirstPerson, Strategy, or Dispatcher");
|
|
MethodInfo? select = typeof(CameraSelector).GetMethod(
|
|
"SelectCamera", BindingFlags.NonPublic | BindingFlags.Instance);
|
|
if (select == null) return McpToolResult.Fail("SelectCamera method unavailable");
|
|
select.Invoke(selector, new object[] { identifier });
|
|
}
|
|
|
|
Camera? camera = Camera.main;
|
|
string details = camera == null
|
|
? ""
|
|
: $" fov={camera.fieldOfView:0.0} pos=({camera.transform.position.x:0.0}," +
|
|
$"{camera.transform.position.y:0.0},{camera.transform.position.z:0.0})";
|
|
return McpToolResult.Ok(
|
|
$"mode={selector.CurrentCameraIdentifier} firstPerson={selector.CurrentCameraIsFirstPerson}" +
|
|
details);
|
|
}
|
|
|
|
static bool _manualCapture;
|
|
static string _manualCaptureLabel = "manual";
|
|
static float _manualCaptureThreshold = 40f;
|
|
static DateTime _manualCaptureStarted;
|
|
static bool _manualDeep;
|
|
static bool _manualUnityLog;
|
|
static bool _previousProfilerEnabled;
|
|
static bool _previousBinaryLog;
|
|
static string _previousProfilerLogFile = "";
|
|
static string _unityLogPath = "";
|
|
|
|
static McpToolResult HitchCapture(JObject args)
|
|
{
|
|
string action = (args["action"]?.Value<string>() ?? "status")
|
|
.Trim().ToLowerInvariant();
|
|
if (action == "status")
|
|
{
|
|
if (!_manualCapture)
|
|
return McpToolResult.Ok("manual hitch capture idle");
|
|
return McpToolResult.Ok(
|
|
$"manual hitch capture active label={_manualCaptureLabel} " +
|
|
$"threshold={_manualCaptureThreshold:0.#}ms " +
|
|
$"deep={_manualDeep} methods={DeepMethodProfiler.PatchedMethods} " +
|
|
$"unityMarkers={UnityMarkerProfiler.ActiveMarkers} " +
|
|
$"playerLoopBoundaries={PlayerLoopProfiler.Boundaries} " +
|
|
$"unityLog={_manualUnityLog} " +
|
|
$"elapsed={(DateTime.Now - _manualCaptureStarted).TotalSeconds:0}s");
|
|
}
|
|
|
|
if (action == "start")
|
|
{
|
|
if (AutomatedBenchmark.Running)
|
|
return McpToolResult.Fail("cannot start manual capture during a benchmark");
|
|
if (_manualCapture || HitchSampler.Active ||
|
|
SparseHitchSampler.Active)
|
|
return McpToolResult.Fail("a hitch capture is already active");
|
|
_manualCaptureLabel = SafeCaptureLabel(
|
|
args["label"]?.Value<string>() ?? "manual-camera");
|
|
_manualCaptureThreshold = Mathf.Clamp(
|
|
args["hitchThresholdMs"]?.Value<float?>() ?? 40f, 16.7f, 1000f);
|
|
_manualDeep = args["deep"]?.Value<bool?>() ?? false;
|
|
_manualUnityLog =
|
|
args["captureUnityBinaryLog"]?.Value<bool?>() ?? false;
|
|
_unityLogPath = "";
|
|
|
|
string deepDetails = "disabled";
|
|
if (_manualDeep &&
|
|
!DeepMethodProfiler.Start(out deepDetails))
|
|
{
|
|
_manualDeep = false;
|
|
return McpToolResult.Fail(
|
|
"could not start deep method profiling: " + deepDetails);
|
|
}
|
|
string markerDetails = _manualDeep
|
|
? UnityMarkerProfiler.Start()
|
|
: "disabled";
|
|
string playerLoopDetails = _manualDeep
|
|
? PlayerLoopProfiler.Start()
|
|
: "disabled";
|
|
|
|
_manualCaptureStarted = DateTime.Now;
|
|
string unityDetails = _manualUnityLog
|
|
? StartUnityProfiler(_manualCaptureLabel)
|
|
: "disabled";
|
|
SparseHitchSampler.Begin(_manualCaptureThreshold);
|
|
_manualCapture = true;
|
|
return McpToolResult.Ok(
|
|
$"manual hitch capture started label={_manualCaptureLabel} " +
|
|
$"threshold={_manualCaptureThreshold:0.#}ms " +
|
|
$"deep={_manualDeep} ({deepDetails}) " +
|
|
$"unityMarkers={markerDetails} playerLoop={playerLoopDetails} " +
|
|
$"unityLog={unityDetails}");
|
|
}
|
|
|
|
if (action != "stop")
|
|
return McpToolResult.Fail("action must be start, status, or stop");
|
|
if (!_manualCapture)
|
|
return McpToolResult.Fail("manual hitch capture is not active");
|
|
|
|
List<HitchFrameRecord> frames = SparseHitchSampler.End();
|
|
_manualCapture = false;
|
|
DeepMethodProfiler.Stop();
|
|
UnityMarkerProfiler.Stop();
|
|
PlayerLoopProfiler.Stop();
|
|
StopUnityProfiler();
|
|
string dir = WriteManualCapture(frames);
|
|
int hitches = 0;
|
|
float worst = 0f;
|
|
for (int i = 0; i < frames.Count; i++)
|
|
{
|
|
if (frames[i].FrameMs >= _manualCaptureThreshold) hitches++;
|
|
if (frames[i].FrameMs > worst) worst = frames[i].FrameMs;
|
|
}
|
|
return McpToolResult.Ok(
|
|
$"manual hitch capture complete frames={frames.Count} " +
|
|
$"hitches>={_manualCaptureThreshold:0.#}ms:{hitches} worst={worst:0.00}ms " +
|
|
$"report={Path.Combine(dir, "report.txt")}" +
|
|
(_unityLogPath.Length > 0 ? $" unityLog={_unityLogPath}" : ""));
|
|
}
|
|
|
|
static string StartUnityProfiler(string label)
|
|
{
|
|
try
|
|
{
|
|
string modPath = Path.GetDirectoryName(typeof(Main).Assembly.Location)
|
|
?? AppDomain.CurrentDomain.BaseDirectory;
|
|
string dir = Path.Combine(modPath, "benchmarks", "unity-profiler");
|
|
Directory.CreateDirectory(dir);
|
|
_unityLogPath = Path.Combine(
|
|
dir,
|
|
label + "-" + DateTime.Now.ToString("yyyyMMdd-HHmmss") + ".raw");
|
|
_previousProfilerEnabled = Profiler.enabled;
|
|
_previousBinaryLog = Profiler.enableBinaryLog;
|
|
_previousProfilerLogFile = Profiler.logFile ?? "";
|
|
Profiler.logFile = _unityLogPath;
|
|
Profiler.enableBinaryLog = true;
|
|
Profiler.enabled = true;
|
|
return _unityLogPath;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_manualUnityLog = false;
|
|
_unityLogPath = "";
|
|
return "failed:" + ex.GetType().Name;
|
|
}
|
|
}
|
|
|
|
static void StopUnityProfiler()
|
|
{
|
|
if (!_manualUnityLog) return;
|
|
try
|
|
{
|
|
Profiler.enabled = _previousProfilerEnabled;
|
|
Profiler.enableBinaryLog = _previousBinaryLog;
|
|
Profiler.logFile = _previousProfilerLogFile;
|
|
}
|
|
catch { }
|
|
_manualUnityLog = false;
|
|
}
|
|
|
|
static string WriteManualCapture(List<HitchFrameRecord> frames)
|
|
{
|
|
string stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
|
|
string modPath = Path.GetDirectoryName(typeof(Main).Assembly.Location)
|
|
?? AppDomain.CurrentDomain.BaseDirectory;
|
|
string dir = Path.Combine(
|
|
modPath, "benchmarks", _manualCaptureLabel + "-" + stamp);
|
|
Directory.CreateDirectory(dir);
|
|
|
|
var report = new StringBuilder();
|
|
int over33 = 0, over50 = 0, over100 = 0, over200 = 0;
|
|
int gcFrames = 0;
|
|
double total = 0;
|
|
float worst = 0;
|
|
var aggregates = new Dictionary<string, double>(StringComparer.Ordinal);
|
|
for (int i = 0; i < frames.Count; i++)
|
|
{
|
|
HitchFrameRecord frame = frames[i];
|
|
total += frame.FrameMs;
|
|
if (frame.FrameMs > worst) worst = frame.FrameMs;
|
|
if (frame.FrameMs >= 33.333f) over33++;
|
|
if (frame.FrameMs >= 50f) over50++;
|
|
if (frame.FrameMs >= 100f) over100++;
|
|
if (frame.FrameMs >= 200f) over200++;
|
|
if (frame.Gc0 != 0 || frame.Gc1 != 0 || frame.Gc2 != 0) gcFrames++;
|
|
foreach (var pair in frame.Probes)
|
|
{
|
|
aggregates.TryGetValue(pair.Key, out double value);
|
|
aggregates[pair.Key] = value + pair.Value.TotalMs;
|
|
}
|
|
}
|
|
var ranked = new List<KeyValuePair<string, double>>(aggregates);
|
|
ranked.Sort((a, b) => b.Value.CompareTo(a.Value));
|
|
|
|
report.AppendLine("S3 manual hitch capture");
|
|
report.AppendLine($"Generated: {DateTime.Now:O}");
|
|
report.AppendLine($"Label: {_manualCaptureLabel}");
|
|
report.AppendLine($"Duration: {(DateTime.Now - _manualCaptureStarted).TotalSeconds:0.0}s");
|
|
report.AppendLine($"Frames: {frames.Count}");
|
|
report.AppendLine($"Average: {(frames.Count > 0 ? total / frames.Count : 0):0.00}ms");
|
|
report.AppendLine($"Worst: {worst:0.00}ms");
|
|
report.AppendLine(
|
|
$"Hitches: >=33ms:{over33} >=50ms:{over50} >=100ms:{over100} >=200ms:{over200}");
|
|
report.AppendLine($"GC frames: {gcFrames}");
|
|
report.AppendLine("Top measured work:");
|
|
for (int i = 0; i < Math.Min(12, ranked.Count); i++)
|
|
report.AppendLine(
|
|
$" {ranked[i].Key}={ranked[i].Value / Math.Max(1, frames.Count):0.000}ms/frame");
|
|
File.WriteAllText(Path.Combine(dir, "report.txt"), report.ToString());
|
|
|
|
var csv = new StringBuilder();
|
|
csv.AppendLine(
|
|
"frame,frame_ms,hitch_bucket,gc0,gc1,gc2,mono_delta_bytes," +
|
|
"camera_x,camera_y,camera_z,top_probe,top_probe_ms");
|
|
var hitches = new StringBuilder();
|
|
for (int i = 0; i < frames.Count; i++)
|
|
{
|
|
HitchFrameRecord frame = frames[i];
|
|
string topId = "";
|
|
double topMs = 0;
|
|
var probes = new JArray();
|
|
foreach (var pair in frame.Probes)
|
|
{
|
|
if (pair.Value.TotalMs > topMs)
|
|
{
|
|
topId = pair.Key;
|
|
topMs = pair.Value.TotalMs;
|
|
}
|
|
probes.Add(new JObject
|
|
{
|
|
["id"] = pair.Key,
|
|
["ms"] = Math.Round(pair.Value.TotalMs, 4),
|
|
["calls"] = pair.Value.Calls,
|
|
["maxMs"] = Math.Round(pair.Value.MaxMs, 4),
|
|
});
|
|
}
|
|
string bucket = frame.FrameMs >= 200f ? "200+" : frame.FrameMs >= 100f ? "100+"
|
|
: frame.FrameMs >= 50f ? "50+" : frame.FrameMs >= 33.333f ? "33+" : "";
|
|
csv.Append(frame.Frame).Append(',').Append(Inv(frame.FrameMs)).Append(',')
|
|
.Append(bucket).Append(',').Append(frame.Gc0).Append(',')
|
|
.Append(frame.Gc1).Append(',').Append(frame.Gc2).Append(',')
|
|
.Append(frame.MonoDelta).Append(',').Append(Inv(frame.CameraPosition.x)).Append(',')
|
|
.Append(Inv(frame.CameraPosition.y)).Append(',')
|
|
.Append(Inv(frame.CameraPosition.z)).Append(',')
|
|
.Append(topId).Append(',').Append(Inv(topMs)).AppendLine();
|
|
|
|
if (frame.FrameMs < _manualCaptureThreshold) continue;
|
|
hitches.AppendLine(new JObject
|
|
{
|
|
["schema"] = 1,
|
|
["frame"] = frame.Frame,
|
|
["frameMs"] = Math.Round(frame.FrameMs, 4),
|
|
["gc0"] = frame.Gc0,
|
|
["gc1"] = frame.Gc1,
|
|
["gc2"] = frame.Gc2,
|
|
["monoDeltaBytes"] = frame.MonoDelta,
|
|
["camera"] = new JObject
|
|
{
|
|
["x"] = Math.Round(frame.CameraPosition.x, 3),
|
|
["y"] = Math.Round(frame.CameraPosition.y, 3),
|
|
["z"] = Math.Round(frame.CameraPosition.z, 3),
|
|
},
|
|
["probes"] = probes,
|
|
}.ToString(Newtonsoft.Json.Formatting.None));
|
|
}
|
|
File.WriteAllText(Path.Combine(dir, "frames.csv"), csv.ToString());
|
|
File.WriteAllText(Path.Combine(dir, "hitches.jsonl"), hitches.ToString());
|
|
return dir;
|
|
}
|
|
|
|
static string SafeCaptureLabel(string value)
|
|
{
|
|
var result = new StringBuilder();
|
|
foreach (char c in value)
|
|
result.Append(char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '-');
|
|
string label = result.ToString().Trim('-');
|
|
return label.Length == 0 ? "manual-camera" : label;
|
|
}
|
|
|
|
static string Inv(double value) =>
|
|
value.ToString("0.000", CultureInfo.InvariantCulture);
|
|
|
|
static McpToolResult ReflectType(JObject args)
|
|
{
|
|
string typeName = args["typeName"]?.Value<string>() ?? "";
|
|
var t = FindType(typeName);
|
|
if (t == null) return McpToolResult.Fail("type not found: " + typeName);
|
|
return McpToolResult.Ok(DumpType(t));
|
|
}
|
|
|
|
static McpToolResult SceneFind(JObject args)
|
|
{
|
|
string contains = args["nameContains"]?.Value<string>() ?? "";
|
|
string component = args["component"]?.Value<string>() ?? "";
|
|
int limit = args["limit"]?.Value<int?>() ?? 30;
|
|
if (limit < 1) limit = 1;
|
|
if (limit > 80) limit = 80;
|
|
|
|
UnityEngine.Object[] found;
|
|
if (!string.IsNullOrEmpty(component))
|
|
{
|
|
var t = FindType(component);
|
|
if (t == null) return McpToolResult.Fail("component type not found: " + component);
|
|
found = UnityEngine.Object.FindObjectsOfType(t);
|
|
}
|
|
else
|
|
{
|
|
found = UnityEngine.Object.FindObjectsOfType<GameObject>();
|
|
}
|
|
|
|
var sb = new StringBuilder();
|
|
int n = 0;
|
|
for (int i = 0; i < found.Length && n < limit; i++)
|
|
{
|
|
var o = found[i];
|
|
if (o == null) continue;
|
|
string name = o.name;
|
|
if (!string.IsNullOrEmpty(contains) &&
|
|
name.IndexOf(contains, StringComparison.OrdinalIgnoreCase) < 0)
|
|
continue;
|
|
sb.AppendLine($"{o.GetType().Name} {name} id={o.GetInstanceID()}");
|
|
n++;
|
|
}
|
|
sb.AppendLine($"shown={n} scanned={found.Length}");
|
|
return McpToolResult.Ok(sb.ToString().TrimEnd());
|
|
}
|
|
|
|
static McpToolResult NatureRenderers(JObject _)
|
|
{
|
|
Type? type = FindType(
|
|
"VisualDesignCafe.Rendering.Nature.NatureRenderer");
|
|
if (type == null)
|
|
return McpToolResult.Fail("NatureRenderer type not found");
|
|
UnityEngine.Object[] found = Resources.FindObjectsOfTypeAll(type);
|
|
string[] properties =
|
|
{
|
|
"IsInitialized",
|
|
"DelayInitialize",
|
|
"AutoRefreshTerrainAtRuntime",
|
|
"RenderTreesWithNatureRenderer",
|
|
"RenderDetailsWithNatureRenderer",
|
|
"OptimizePatchSize",
|
|
"OnlyInitializeWithinRenderingDistance",
|
|
"Draw",
|
|
"DetailDistance",
|
|
"ReduceDensityDistance",
|
|
"ReduceDensityAmount",
|
|
"ShadowDistance",
|
|
"StreamProcessorLimit",
|
|
"StreamInDistance",
|
|
"StreamOutDistance",
|
|
"StreamPrioritizeView",
|
|
};
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("assembly=" + type.Assembly.Location);
|
|
Type? streamerType = FindType(
|
|
"VisualDesignCafe.Rendering.Nature.TerrainGrassStreamer");
|
|
if (streamerType != null)
|
|
{
|
|
FieldInfo? budgetField = streamerType.GetField(
|
|
"_globalStreamingBudget",
|
|
BindingFlags.Static | BindingFlags.NonPublic);
|
|
FieldInfo? nearbyField = streamerType.GetField(
|
|
"_globalNearbyCellLoading",
|
|
BindingFlags.Static | BindingFlags.NonPublic);
|
|
sb.Append("grassGlobals budget=")
|
|
.Append(budgetField?.GetValue(null) ?? "unknown")
|
|
.Append(" forceNearby=")
|
|
.Append(nearbyField?.GetValue(null) ?? "unknown")
|
|
.AppendLine();
|
|
}
|
|
BaseGamePerfSettings baseSettings = BaseGamePerfModule.Settings;
|
|
sb.Append("s3Nature active=")
|
|
.Append(Main.Registry.IsActive("basegame"))
|
|
.Append(" enabled=")
|
|
.Append(baseSettings.natureStreamingSmoothingEnabled)
|
|
.Append(" budget=")
|
|
.Append(baseSettings.grassInstanceBudgetPerFrame)
|
|
.Append(" queueNearby=")
|
|
.Append(baseSettings.queueNearbyGrassLoads)
|
|
.Append(" unloadSpread=")
|
|
.Append(baseSettings.grassUnloadSpreadFrames)
|
|
.Append(" distanceCull=")
|
|
.Append(baseSettings.distanceCullNatureTerrains)
|
|
.AppendLine();
|
|
for (int i = 0; i < found.Length; i++)
|
|
{
|
|
UnityEngine.Object item = found[i];
|
|
if (item == null) continue;
|
|
sb.Append(i).Append(": ").Append(item.name)
|
|
.Append(" id=").Append(item.GetInstanceID());
|
|
if (item is Component component)
|
|
sb.Append(" active=")
|
|
.Append(component.gameObject.activeInHierarchy);
|
|
sb.AppendLine();
|
|
for (int p = 0; p < properties.Length; p++)
|
|
{
|
|
PropertyInfo? property = type.GetProperty(
|
|
properties[p],
|
|
BindingFlags.Instance |
|
|
BindingFlags.Public |
|
|
BindingFlags.NonPublic);
|
|
if (property == null || property.GetIndexParameters().Length != 0)
|
|
continue;
|
|
try
|
|
{
|
|
object? value = property.GetValue(item);
|
|
sb.Append(" ").Append(properties[p]).Append('=')
|
|
.Append(value ?? "null").AppendLine();
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
sb.AppendLine($"count={found.Length}");
|
|
return McpToolResult.Ok(sb.ToString().TrimEnd());
|
|
}
|
|
|
|
static McpToolResult BaseGameSet(JObject args)
|
|
{
|
|
BaseGamePerfSettings settings = BaseGamePerfModule.Settings;
|
|
if (args["gcSmoothing"] != null)
|
|
settings.gcSmoothingEnabled =
|
|
args["gcSmoothing"]!.Value<bool>();
|
|
if (args["incrementalSliceMs"] != null)
|
|
settings.incrementalSliceMs = Mathf.Clamp(
|
|
args["incrementalSliceMs"]!.Value<float>(), 0.25f, 5f);
|
|
if (args["natureStreamingSmoothing"] != null)
|
|
settings.natureStreamingSmoothingEnabled =
|
|
args["natureStreamingSmoothing"]!.Value<bool>();
|
|
if (args["grassInstanceBudget"] != null)
|
|
settings.grassInstanceBudgetPerFrame = Mathf.Clamp(
|
|
args["grassInstanceBudget"]!.Value<int>(), 64, 4096);
|
|
if (args["queueNearbyGrass"] != null)
|
|
settings.queueNearbyGrassLoads =
|
|
args["queueNearbyGrass"]!.Value<bool>();
|
|
if (args["grassUnloadSpreadFrames"] != null)
|
|
settings.grassUnloadSpreadFrames = Mathf.Clamp(
|
|
args["grassUnloadSpreadFrames"]!.Value<int>(), 0, 600);
|
|
if (args["distanceCullNatureTerrains"] != null)
|
|
settings.distanceCullNatureTerrains =
|
|
args["distanceCullNatureTerrains"]!.Value<bool>();
|
|
|
|
if (Main.Registry.IsActive("basegame"))
|
|
BaseGamePerfModule.ApplyRuntimeSettings();
|
|
if (args["persist"]?.Value<bool?>() == true)
|
|
BaseGamePerfModule.Persist();
|
|
|
|
return McpToolResult.Ok(
|
|
$"gc={settings.gcSmoothingEnabled} " +
|
|
$"slice={settings.incrementalSliceMs:0.##}ms " +
|
|
$"nature={settings.natureStreamingSmoothingEnabled} " +
|
|
$"grassBudget={settings.grassInstanceBudgetPerFrame} " +
|
|
$"queueNearby={settings.queueNearbyGrassLoads} " +
|
|
$"unloadSpread={settings.grassUnloadSpreadFrames}frames " +
|
|
$"distanceCull={settings.distanceCullNatureTerrains}");
|
|
}
|
|
|
|
static McpToolResult RenderStats(JObject args)
|
|
{
|
|
Camera? cam = Camera.main;
|
|
try { MainCameraHelper.TryGetIfNeeded(ref cam); }
|
|
catch { }
|
|
if (cam == null) return McpToolResult.Fail("no camera");
|
|
|
|
float feet = args["maxDistanceFeet"]?.Value<float?>() ?? 500f;
|
|
float maxDistance = Mathf.Max(100f, feet * 0.3048f + 80f);
|
|
float maxDistanceSq = maxDistance * maxDistance;
|
|
Plane[] planes = GeometryUtility.CalculateFrustumPlanes(cam);
|
|
var byLayer = new Dictionary<int, long[]>();
|
|
Renderer[] renderers = UnityEngine.Object.FindObjectsOfType<Renderer>();
|
|
int active = 0;
|
|
long vertices = 0;
|
|
long triangles = 0;
|
|
|
|
for (int i = 0; i < renderers.Length; i++)
|
|
{
|
|
Renderer r = renderers[i];
|
|
if (r == null || !r.enabled || !r.gameObject.activeInHierarchy) continue;
|
|
int layer = r.gameObject.layer;
|
|
if ((cam.cullingMask & (1 << layer)) == 0) continue;
|
|
Bounds bounds = r.bounds;
|
|
if ((bounds.center - cam.transform.position).sqrMagnitude > maxDistanceSq) continue;
|
|
if (!GeometryUtility.TestPlanesAABB(planes, bounds)) continue;
|
|
|
|
Mesh? mesh = null;
|
|
if (r is SkinnedMeshRenderer skin)
|
|
mesh = skin.sharedMesh;
|
|
else
|
|
{
|
|
MeshFilter? filter = r.GetComponent<MeshFilter>();
|
|
if (filter != null) mesh = filter.sharedMesh;
|
|
}
|
|
long meshVertices = mesh != null ? mesh.vertexCount : 0;
|
|
long meshTriangles = 0;
|
|
if (mesh != null)
|
|
{
|
|
try
|
|
{
|
|
for (int s = 0; s < mesh.subMeshCount; s++)
|
|
meshTriangles += (long)mesh.GetIndexCount(s) / 3L;
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
if (!byLayer.TryGetValue(layer, out long[]? values))
|
|
{
|
|
values = new long[3];
|
|
byLayer[layer] = values;
|
|
}
|
|
values[0]++;
|
|
values[1] += meshVertices;
|
|
values[2] += meshTriangles;
|
|
active++;
|
|
vertices += meshVertices;
|
|
triangles += meshTriangles;
|
|
}
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine(
|
|
$"camera={cam.name} distance={feet:0}ft scanned={renderers.Length} " +
|
|
$"frustum={active} verts={vertices} tris={triangles}");
|
|
for (int layer = 0; layer < 32; layer++)
|
|
{
|
|
if (!byLayer.TryGetValue(layer, out long[]? values)) continue;
|
|
string name = LayerMask.LayerToName(layer);
|
|
if (string.IsNullOrEmpty(name)) name = "(unnamed)";
|
|
sb.AppendLine(
|
|
$" layer={layer} {name,-16} renderers={values[0],5} " +
|
|
$"verts={values[1],9} tris={values[2],9}");
|
|
}
|
|
return McpToolResult.Ok(sb.ToString().TrimEnd());
|
|
}
|
|
|
|
static Type? FindType(string name)
|
|
{
|
|
if (string.IsNullOrEmpty(name)) return null;
|
|
var t = Type.GetType(name);
|
|
if (t != null) return t;
|
|
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
|
{
|
|
try
|
|
{
|
|
t = asm.GetType(name);
|
|
if (t != null) return t;
|
|
foreach (Type x in asm.GetTypes())
|
|
{
|
|
if (x.Name == name || x.FullName == name)
|
|
return x;
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static string DumpType(Type t)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine(t.FullName);
|
|
const BindingFlags F = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
|
|
foreach (var p in t.GetProperties(F))
|
|
sb.AppendLine($" prop {p.PropertyType.Name} {p.Name}");
|
|
foreach (var f in t.GetFields(F))
|
|
sb.AppendLine($" field {f.FieldType.Name} {f.Name}");
|
|
foreach (var m in t.GetMethods(F))
|
|
{
|
|
if (m.IsSpecialName) continue;
|
|
sb.AppendLine($" method {m.Name}()");
|
|
}
|
|
return sb.ToString().TrimEnd();
|
|
}
|
|
|
|
static string DumpObject(object obj, int maxDepth, int depth, HashSet<int> seen)
|
|
{
|
|
var sb = new StringBuilder();
|
|
DumpObject(sb, obj, maxDepth, depth, seen);
|
|
return sb.ToString().TrimEnd();
|
|
}
|
|
|
|
static void DumpObject(StringBuilder sb, object? obj, int maxDepth, int depth, HashSet<int> seen)
|
|
{
|
|
string pad = new string(' ', depth * 2);
|
|
if (obj == null) { sb.Append(pad).AppendLine("null"); return; }
|
|
Type t = obj.GetType();
|
|
if (obj is UnityEngine.Object uo)
|
|
{
|
|
int id = uo.GetInstanceID();
|
|
if (!seen.Add(id) && depth > 0)
|
|
{
|
|
sb.Append(pad).Append(t.Name).Append(" #").Append(id).AppendLine(" (seen)");
|
|
return;
|
|
}
|
|
}
|
|
sb.Append(pad).Append(t.Name);
|
|
if (obj is UnityEngine.Object u2)
|
|
sb.Append(" name=").Append(u2.name).Append(" id=").Append(u2.GetInstanceID());
|
|
sb.AppendLine();
|
|
if (depth >= maxDepth) return;
|
|
|
|
const BindingFlags F = BindingFlags.Public | BindingFlags.Instance;
|
|
int n = 0;
|
|
foreach (var p in t.GetProperties(F))
|
|
{
|
|
if (n >= 40) { sb.Append(pad).AppendLine(" ..."); break; }
|
|
if (p.GetIndexParameters().Length > 0) continue;
|
|
if (p.Name == "gameObject" || p.Name == "transform" || p.Name == "rigidbody") continue;
|
|
object? val;
|
|
try { val = p.GetValue(obj, null); }
|
|
catch { continue; }
|
|
n++;
|
|
AppendValue(sb, pad + " ", p.Name, val, maxDepth, depth, seen);
|
|
}
|
|
}
|
|
|
|
static void AppendValue(StringBuilder sb, string pad, string name, object? val, int maxDepth, int depth, HashSet<int> seen)
|
|
{
|
|
if (val == null) { sb.Append(pad).Append(name).AppendLine(" = null"); return; }
|
|
Type t = val.GetType();
|
|
if (t.IsPrimitive || val is string || val is decimal || val is Enum)
|
|
{
|
|
sb.Append(pad).Append(name).Append(" = ").Append(val).AppendLine();
|
|
return;
|
|
}
|
|
if (val is Vector3 v)
|
|
{
|
|
sb.Append(pad).Append(name).AppendLine($" = ({v.x:0.00},{v.y:0.00},{v.z:0.00})");
|
|
return;
|
|
}
|
|
if (depth + 1 >= maxDepth)
|
|
{
|
|
sb.Append(pad).Append(name).Append(" = ").Append(t.Name).AppendLine();
|
|
return;
|
|
}
|
|
sb.Append(pad).Append(name).AppendLine(":");
|
|
DumpObject(sb, val, maxDepth, depth + 1, seen);
|
|
}
|
|
|
|
static string Schema(params (string name, string type)[] props)
|
|
{
|
|
var o = new JObject { ["type"] = "object", ["additionalProperties"] = false };
|
|
var p = new JObject();
|
|
foreach (var (name, type) in props)
|
|
p[name] = new JObject { ["type"] = type };
|
|
o["properties"] = p;
|
|
return o.ToString(Newtonsoft.Json.Formatting.None);
|
|
}
|
|
|
|
static string Schema(string name, string type) => Schema((name, type));
|
|
}
|