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() ?? 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 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() ?? "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() ?? 5f, Label = args["label"]?.Value() ?? "s3", MotionMode = args["motionMode"]?.Value() ?? "orbit", DisabledModules = args["disabledModules"]?.Value() ?? "", BaselineDisabledModules = args["baselineDisabledModules"]?.Value() ?? "", ScenarioDisabledModules = args["scenarioDisabledModules"]?.Value() ?? "", CaptureHitchProbes = args["captureHitchProbes"]?.Value(), HitchThresholdMs = args["hitchThresholdMs"]?.Value(), CaptureUnityBinaryLog = args["captureUnityBinaryLog"]?.Value(), CameraX = args["cameraX"]?.Value(), CameraY = args["cameraY"]?.Value(), CameraZ = args["cameraZ"]?.Value(), CameraPitch = args["cameraPitch"]?.Value(), CameraYaw = args["cameraYaw"]?.Value(), CameraFov = args["cameraFov"]?.Value(), }; 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() ?? ""; bool active = args["active"]?.Value() ?? true; bool persist = args["persist"]?.Value() ?? 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() ?? 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() ?? ""))); 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() ?? 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() : 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(); 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() ?? "selected").ToLowerInvariant(); int depth = args["maxDepth"]?.Value() ?? 2; if (depth < 1) depth = 1; if (depth > 4) depth = 4; object? obj = null; if (target == "type") { string typeName = args["typeName"]?.Value() ?? ""; 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(); return McpToolResult.Ok(DumpObject(obj, depth, 0, seen)); } McpToolResult Screenshot(JObject args) { int maxWidth = args["maxWidth"]?.Value() ?? 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() ?? "").ToLowerInvariant(); string filter = args["filter"]?.Value() ?? ""; 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() ?? ""; 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() ?? "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() ?? "manual-camera"); _manualCaptureThreshold = Mathf.Clamp( args["hitchThresholdMs"]?.Value() ?? 40f, 16.7f, 1000f); _manualDeep = args["deep"]?.Value() ?? false; _manualUnityLog = args["captureUnityBinaryLog"]?.Value() ?? 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 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 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(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>(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() ?? ""; 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 component = args["component"]?.Value() ?? ""; int limit = args["limit"]?.Value() ?? 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(); } 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(); if (args["incrementalSliceMs"] != null) settings.incrementalSliceMs = Mathf.Clamp( args["incrementalSliceMs"]!.Value(), 0.25f, 5f); if (args["natureStreamingSmoothing"] != null) settings.natureStreamingSmoothingEnabled = args["natureStreamingSmoothing"]!.Value(); if (args["grassInstanceBudget"] != null) settings.grassInstanceBudgetPerFrame = Mathf.Clamp( args["grassInstanceBudget"]!.Value(), 64, 4096); if (args["queueNearbyGrass"] != null) settings.queueNearbyGrassLoads = args["queueNearbyGrass"]!.Value(); if (args["grassUnloadSpreadFrames"] != null) settings.grassUnloadSpreadFrames = Mathf.Clamp( args["grassUnloadSpreadFrames"]!.Value(), 0, 600); if (args["distanceCullNatureTerrains"] != null) settings.distanceCullNatureTerrains = args["distanceCullNatureTerrains"]!.Value(); if (Main.Registry.IsActive("basegame")) BaseGamePerfModule.ApplyRuntimeSettings(); if (args["persist"]?.Value() == 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() ?? 500f; float maxDistance = Mathf.Max(100f, feet * 0.3048f + 80f); float maxDistanceSq = maxDistance * maxDistance; Plane[] planes = GeometryUtility.CalculateFrustumPlanes(cam); var byLayer = new Dictionary(); Renderer[] renderers = UnityEngine.Object.FindObjectsOfType(); 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(); 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 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 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 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)); }