diff --git a/README.md b/README.md index fbd49a2..e8bfac1 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ I originally planned on releasing individual mods, but considering my workflow o | Quick Actions | Extra outer-ring couple/air/cut actions and a consist hover wheel on the rolling-stock pie menu. | | Car Cards | Fanned consist dock for the selected cut, with waybill, notes, and couple/handbrake/locate actions. | | Industry Tags | In-world business, track, and yard callouts with live industry data. | +| MCP | Localhost agent server so a coding tool can inspect the live game. Disabled by default. | All modules are disabled by default; enable them per-module from the S³ settings page. A game restart is required for enable/disable to take effect. More modules will follow; S³ is designed to grow. @@ -232,6 +233,16 @@ the strategy camera. Hover highlights the related tracks. Console: `/s3ind dump` --- +## MCP + +A loopback-only HTTP server so a local coding agent can observe the live game +and, with gates on, control it or reload tool DLLs. Enable it from the S³ +settings page. Default bind is 127.0.0.1. Console: `/s3mcp`. + +This is a developer module. Leave it off unless you are driving S³ from an agent. + +--- + ## Migrating from the standalone mods S³ replaces the separate **Physics Optimizer** (`RailroaderPhysicsOverhaul`) and diff --git a/dist/build-common.ps1 b/dist/build-common.ps1 index 60adffe..566e48c 100644 --- a/dist/build-common.ps1 +++ b/dist/build-common.ps1 @@ -28,6 +28,17 @@ function Invoke-ManagedBuild { return $dll } +function Invoke-McpToolsBuild { + param([string]$Configuration = "Release", [string]$GameDir = "") + + Write-Host "=== Building MCP tools (S3.Mcp.Tools.dll, $Configuration) ===" -ForegroundColor Cyan + $csproj = Join-Path $RepoRoot "mcp-tools\S3.Mcp.Tools.csproj" + $args = @($csproj, "-c", $Configuration, "--nologo", "-v", "minimal") + if ($GameDir) { $args += "/p:GameDir=$GameDir" } + dotnet build @args | Out-Host + if ($LASTEXITCODE -ne 0) { throw "MCP tools build failed." } +} + function Invoke-NativeBuild { param([string]$Configuration = "Release") @@ -50,6 +61,51 @@ function Invoke-NativeBuild { return $dll } +function Test-ModDllLocked { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path $Path)) { return $false } + try { + [System.IO.File]::Open($Path, 'Open', 'ReadWrite', 'None').Close() + return $false + } catch { + return $true + } +} + +function Get-LockedModDlls { + param([Parameter(Mandatory)][string]$DestModDir) + $locked = @() + foreach ($name in @("S3.dll", "S3Native.dll")) { + if (Test-ModDllLocked (Join-Path $DestModDir $name)) { $locked += $name } + } + return $locked +} + +# Poll until Mods\S3 DLLs are writable. Railroader.exe is the usual locker; +# we wait on the file itself so a hung Unity process is still detected. +function Wait-ModUnlocked { + param( + [Parameter(Mandatory)][string]$DestModDir, + [int]$PollSeconds = 2 + ) + $locked = @(Get-LockedModDlls $DestModDir) + if ($locked.Count -eq 0) { return } + + Write-Host "=== Waiting for Railroader to close ($($locked -join ', ') locked) ===" -ForegroundColor Yellow + Write-Host " Close the game; install will run automatically. Ctrl+C to abort." -ForegroundColor DarkYellow + while ($true) { + Start-Sleep -Seconds $PollSeconds + $still = @(Get-LockedModDlls $DestModDir) + if ($still.Count -eq 0) { + Write-Host "=== Unlocked, installing ===" -ForegroundColor Green + return + } + $rr = Get-Process -Name "Railroader" -ErrorAction SilentlyContinue + $hint = if ($rr) { "Railroader.exe still running" } else { "$($still -join ', ') still locked" } + Write-Host " ... $hint" -ForegroundColor DarkGray + } +} + # Assembles the Mods\S3 layout into $DestModDir (the S3 folder itself). # # Overwrites in place rather than wiping the folder. A full wipe is dangerous: if @@ -60,18 +116,20 @@ function Invoke-NativeBuild { function New-ModLayout { param([Parameter(Mandatory)][string]$DestModDir, [Parameter(Mandatory)][string]$ManagedDll, - [string]$NativeDll = $null) + [string]$NativeDll = $null, + [switch]$Wait) New-Item -ItemType Directory -Force -Path $DestModDir | Out-Null + if ($Wait) { + Wait-ModUnlocked -DestModDir $DestModDir + } + # Fail fast with a clear message if a DLL we're about to overwrite is locked # (game still running) — before touching anything. - foreach ($name in @("S3.dll", "S3Native.dll")) { - $path = Join-Path $DestModDir $name - if (Test-Path $path) { - try { [System.IO.File]::Open($path, 'Open', 'ReadWrite', 'None').Close() } - catch { throw "$name is locked - close Railroader before installing." } - } + $locked = @(Get-LockedModDlls $DestModDir) + if ($locked.Count -gt 0) { + throw "$($locked -join ', ') is locked - close Railroader before installing (or rerun with -Wait)." } # Drop stale artifacts from older builds (the pre-rename native DLL, ngen @@ -82,6 +140,8 @@ function New-ModLayout { Copy-Item (Join-Path $RepoRoot "Info.json") (Join-Path $DestModDir "Info.json") -Force Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -Force + $icon = Join-Path $RepoRoot "src\Modules\QuickActions\Icons\consist.png" + if (Test-Path $icon) { Copy-Item $icon (Join-Path $DestModDir "consist.png") -Force } if ($NativeDll) { Copy-Item $NativeDll (Join-Path $DestModDir "S3Native.dll") -Force # Ship ProggyClean.ttf alongside the native DLL so IMGUI_DISABLE_DEFAULT_FONT diff --git a/dist/build-local.ps1 b/dist/build-local.ps1 index 8aef0bf..40e9bdd 100644 --- a/dist/build-local.ps1 +++ b/dist/build-local.ps1 @@ -1,20 +1,38 @@ # Build S³ and install it straight into the local game copy for testing. # .\dist\build-local.ps1 +# .\dist\build-local.ps1 -Wait +# .\dist\build-local.ps1 -ToolsOnly # .\dist\build-local.ps1 -GameDir "X:\path\to\Railroader" -Configuration Debug +# +# -Wait: build now, then poll until Railroader releases Mods\S3\*.dll and copy. +# Use this when the game is open (S3Native.dll stays locked until quit). +# +# -ToolsOnly: rebuild the reloadable MCP tools DLL and copy it to +# Mods\S3\plugins\ while the game is running. The MCP host auto-reloads it. param( [string]$Configuration = "Release", - [string]$GameDir = "D:\Seton\SteamApps\steamapps\common\Railroader" + [string]$GameDir = "D:\Seton\SteamApps\steamapps\common\Railroader", + [switch]$Wait, + [switch]$ToolsOnly ) . (Join-Path $PSScriptRoot "build-common.ps1") if (-not (Test-Path $GameDir)) { throw "GameDir not found: $GameDir" } +if ($ToolsOnly) { + Invoke-McpToolsBuild -Configuration $Configuration -GameDir $GameDir + Write-Host "=== MCP tools copied to $GameDir\Mods\S3\plugins ===" -ForegroundColor Green + Write-Host " Host auto-reloads S3.Mcp.Tools.dll (or /s3mcp reload)." + return +} + $managed = Invoke-ManagedBuild -Configuration $Configuration -GameDir $GameDir $native = Invoke-NativeBuild -Configuration $Configuration $modDir = Join-Path $GameDir "Mods\$ModId" -New-ModLayout -DestModDir $modDir -ManagedDll $managed -NativeDll $native +New-ModLayout -DestModDir $modDir -ManagedDll $managed -NativeDll $native -Wait:$Wait +Invoke-McpToolsBuild -Configuration $Configuration -GameDir $GameDir Write-Host "=== Installed to $modDir ===" -ForegroundColor Green diff --git a/dist/build-mcp-tools.ps1 b/dist/build-mcp-tools.ps1 new file mode 100644 index 0000000..5a073f1 --- /dev/null +++ b/dist/build-mcp-tools.ps1 @@ -0,0 +1,7 @@ +# Rebuild only the reloadable MCP tools pack (game can stay open). +# .\dist\build-mcp-tools.ps1 +param( + [string]$Configuration = "Release", + [string]$GameDir = "D:\Seton\SteamApps\steamapps\common\Railroader" +) +& (Join-Path $PSScriptRoot "build-local.ps1") -Configuration $Configuration -GameDir $GameDir -ToolsOnly diff --git a/mcp-tools/DeepMethodProfiler.cs b/mcp-tools/DeepMethodProfiler.cs new file mode 100644 index 0000000..fd3a8aa --- /dev/null +++ b/mcp-tools/DeepMethodProfiler.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.CompilerServices; +using HarmonyLib; +using S3.Modules.Profiler; +using UnityEngine; + +namespace S3.Mcp.Tools; + +/// +/// Temporary, capture-scoped timings around game MonoBehaviour lifecycle methods. +/// This deliberately lives in the reloadable MCP tools pack so profiling coverage +/// can be refined without restarting the game. +/// +internal static class DeepMethodProfiler +{ + static readonly string[] LifecycleMethods = + { + "FixedUpdate", + "Update", + "LateUpdate", + "OnPreCull", + "OnPreRender", + "OnPostRender", + "OnWillRenderObject", + }; + + static readonly string[] ExcludedAssemblyPrefixes = + { + "System", + "Microsoft", + "Mono.", + "mscorlib", + "netstandard", + "0Harmony", + "Newtonsoft.", + "UnityModManager", + "S3", + }; + + static readonly Dictionary ProbeIds = new(); + static Harmony? _harmony; + + public static bool Active => _harmony != null; + public static int PatchedMethods => ProbeIds.Count; + + public static bool Start(out string details) + { + if (_harmony != null) + { + details = $"already active ({ProbeIds.Count} methods)"; + return true; + } + + string harmonyId = + "S3.mcp.deep." + typeof(DeepMethodProfiler).Assembly.GetName().Name; + var harmony = new Harmony(harmonyId); + var prefix = new HarmonyMethod( + typeof(DeepMethodProfiler), nameof(Prefix)); + var postfix = new HarmonyMethod( + typeof(DeepMethodProfiler), nameof(Postfix)); + int patched = 0; + int lifecyclePatched = 0; + int stateMachinePatched = 0; + int failed = 0; + + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + if (!IsCandidateAssembly(assembly)) continue; + foreach (Type type in GetTypesSafely(assembly)) + { + if (type == null || type.IsAbstract) continue; + if (typeof(MonoBehaviour).IsAssignableFrom(type)) + { + for (int i = 0; i < LifecycleMethods.Length; i++) + { + MethodInfo? method = FindParameterlessMethod( + type, LifecycleMethods[i]); + if (!CanPatch(method)) continue; + if (TryPatch( + harmony, method!, prefix, postfix, + $"deep.{method!.Name}:{DisplayTypeName(type)}")) + { + patched++; + lifecyclePatched++; + } + else failed++; + } + } + + bool coroutine = typeof(IEnumerator).IsAssignableFrom(type); + bool asyncStateMachine = + typeof(IAsyncStateMachine).IsAssignableFrom(type); + if (coroutine || asyncStateMachine) + { + MethodInfo? moveNext = FindParameterlessMethod(type, "MoveNext"); + if (!CanPatch(moveNext)) continue; + string kind = coroutine ? "Coroutine" : "Async"; + if (TryPatch( + harmony, moveNext!, prefix, postfix, + $"deep.{kind}:{DisplayTypeName(type)}")) + { + patched++; + stateMachinePatched++; + } + else failed++; + } + } + } + + if (patched == 0) + { + harmony.UnpatchAll(harmonyId); + ProbeIds.Clear(); + details = $"no methods patched (failures={failed})"; + return false; + } + + _harmony = harmony; + details = + $"patched={patched} lifecycle={lifecyclePatched} " + + $"stateMachines={stateMachinePatched} failures={failed}"; + return true; + } + + public static void Stop() + { + Harmony? harmony = _harmony; + _harmony = null; + if (harmony != null) + { + try { harmony.UnpatchAll(harmony.Id); } + catch { } + } + ProbeIds.Clear(); + } + + static bool IsCandidateAssembly(Assembly assembly) + { + string name; + try { name = assembly.GetName().Name ?? ""; } + catch { return false; } + if (name.Equals("S3", StringComparison.OrdinalIgnoreCase)) + return true; + + for (int i = 0; i < ExcludedAssemblyPrefixes.Length; i++) + { + if (name.StartsWith( + ExcludedAssemblyPrefixes[i], + StringComparison.OrdinalIgnoreCase)) + return false; + } + return true; + } + + static Type[] GetTypesSafely(Assembly assembly) + { + try { return assembly.GetTypes(); } + catch (ReflectionTypeLoadException ex) + { + var result = new List(); + Type?[] types = ex.Types; + for (int i = 0; i < types.Length; i++) + if (types[i] != null) result.Add(types[i]!); + return result.ToArray(); + } + catch { return Array.Empty(); } + } + + static MethodInfo? FindParameterlessMethod(Type type, string name) + { + MethodInfo[] methods; + try + { + methods = type.GetMethods( + BindingFlags.Instance | + BindingFlags.Public | + BindingFlags.NonPublic | + BindingFlags.DeclaredOnly); + } + catch { return null; } + + for (int i = 0; i < methods.Length; i++) + if (methods[i].Name == name && + methods[i].GetParameters().Length == 0) + return methods[i]; + return null; + } + + static bool CanPatch(MethodInfo? method) + { + if (method == null || method.IsAbstract || + method.ContainsGenericParameters) + return false; + try { return method.GetMethodBody() != null; } + catch { return false; } + } + + static bool TryPatch( + Harmony harmony, + MethodInfo method, + HarmonyMethod prefix, + HarmonyMethod postfix, + string id) + { + try + { + ProbeIds[method] = id; + harmony.Patch(method, prefix, postfix); + return true; + } + catch + { + ProbeIds.Remove(method); + return false; + } + } + + static string DisplayTypeName(Type type) + { + string name = type.FullName ?? type.Name; + Type? declaring = type.DeclaringType; + if (declaring == null) return name; + return (declaring.FullName ?? declaring.Name) + "." + type.Name; + } + + static void Prefix(out long __state) + { + __state = Stopwatch.GetTimestamp(); + } + + static void Postfix(MethodBase __originalMethod, long __state) + { + if (__state == 0 || !SparseHitchSampler.Active) return; + if (!ProbeIds.TryGetValue(__originalMethod, out string? id)) return; + SparseHitchSampler.Record(id, Stopwatch.GetTimestamp() - __state); + } +} diff --git a/mcp-tools/PlayerLoopProfiler.cs b/mcp-tools/PlayerLoopProfiler.cs new file mode 100644 index 0000000..dd2c42e --- /dev/null +++ b/mcp-tools/PlayerLoopProfiler.cs @@ -0,0 +1,116 @@ +using System; +using System.Diagnostics; +using S3.Modules.Profiler; +using UnityEngine.LowLevel; + +namespace S3.Mcp.Tools; + +/// +/// Temporarily inserts timestamp boundaries around every Unity PlayerLoop +/// subsystem. This measures native engine phases that Harmony cannot patch. +/// +internal static class PlayerLoopProfiler +{ + static PlayerLoopSystem _original; + static long[] _depthStarts = Array.Empty(); + + public static bool Active { get; private set; } + public static int Boundaries { get; private set; } + + public static string Start() + { + Stop(); + try + { + _original = PlayerLoop.GetCurrentPlayerLoop(); + PlayerLoopSystem instrumented = _original; + Boundaries = 0; + int maxDepth = MeasureDepth(instrumented, 0); + _depthStarts = new long[Math.Max(2, maxDepth + 2)]; + Instrument(ref instrumented, 0, "PlayerLoop"); + PlayerLoop.SetPlayerLoop(instrumented); + Active = true; + return $"boundaries={Boundaries} depth={maxDepth}"; + } + catch (Exception ex) + { + Stop(); + return "failed:" + ex.GetType().Name; + } + } + + public static void Stop() + { + if (Active) + { + try { PlayerLoop.SetPlayerLoop(_original); } + catch { } + } + Active = false; + Boundaries = 0; + _depthStarts = Array.Empty(); + } + + static int MeasureDepth(PlayerLoopSystem system, int depth) + { + int max = depth; + PlayerLoopSystem[]? children = system.subSystemList; + if (children == null) return max; + for (int i = 0; i < children.Length; i++) + max = Math.Max(max, MeasureDepth(children[i], depth + 1)); + return max; + } + + static void Instrument( + ref PlayerLoopSystem system, + int depth, + string parentPath) + { + PlayerLoopSystem[]? children = system.subSystemList; + if (children == null || children.Length == 0) return; + + var expanded = new PlayerLoopSystem[children.Length * 2 + 1]; + expanded[0] = Boundary(depth, null); + for (int i = 0; i < children.Length; i++) + { + PlayerLoopSystem child = children[i]; + string childName = DisplayName(child.type, i); + string path = parentPath + "." + childName; + Instrument(ref child, depth + 1, path); + expanded[i * 2 + 1] = child; + expanded[i * 2 + 2] = Boundary(depth, "loop." + path); + Boundaries++; + } + system.subSystemList = expanded; + } + + static PlayerLoopSystem Boundary(int depth, string? completedId) + { + return new PlayerLoopSystem + { + type = typeof(PlayerLoopProfiler), + updateDelegate = () => Mark(depth, completedId), + }; + } + + static void Mark(int depth, string? completedId) + { + long now = Stopwatch.GetTimestamp(); + if (completedId != null && + SparseHitchSampler.Active && + depth < _depthStarts.Length) + { + long start = _depthStarts[depth]; + if (start != 0 && now >= start) + SparseHitchSampler.Record(completedId, now - start); + } + if (depth < _depthStarts.Length) + _depthStarts[depth] = now; + } + + static string DisplayName(Type? type, int index) + { + if (type == null) return "unnamed" + index; + return type.FullName ?? type.Name; + } +} diff --git a/mcp-tools/S3.Mcp.Tools.csproj b/mcp-tools/S3.Mcp.Tools.csproj new file mode 100644 index 0000000..9dbd95d --- /dev/null +++ b/mcp-tools/S3.Mcp.Tools.csproj @@ -0,0 +1,60 @@ + + + + netstandard2.1 + latest + annotations + + $([System.DateTime]::UtcNow.ToString('yyyyMMddHHmmssfff')) + S3.Mcp.Tools_$(ToolsStamp) + S3.Mcp.Tools + false + false + false + $(MSBuildThisFileDirectory)..\src\bin\$(Configuration)\S3.dll + + + + + $(S3Dll) + false + + + $(GameManaged)\Assembly-CSharp.dll + false + + + $(GameManaged)\Newtonsoft.Json.dll + false + + + $(GameManaged)\UnityEngine.dll + false + + + $(GameManaged)\UnityEngine.CoreModule.dll + false + + + $(GameManaged)\UnityEngine.PhysicsModule.dll + false + + + $(GameManaged)\UnityEngine.InputLegacyModule.dll + false + + + $(UmmDir)\0Harmony.dll + false + + + + + + + + + + diff --git a/mcp-tools/SparseHitchSampler.cs b/mcp-tools/SparseHitchSampler.cs new file mode 100644 index 0000000..c69bf6d --- /dev/null +++ b/mcp-tools/SparseHitchSampler.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using S3.Modules.Profiler; +using UnityEngine; + +namespace S3.Mcp.Tools; + +/// +/// Low-allocation manual sampler. It stores full probe detail only for hitch +/// frames and ignores sub-0.1ms samples, avoiding profiler-induced GC stalls. +/// +internal static class SparseHitchSampler +{ + static readonly Dictionary Current = + new(StringComparer.Ordinal); + static readonly List Frames = new(4096); + static GameObject? _driverObject; + static float _threshold; + static int _frame; + static int _gc0; + static int _gc1; + static int _gc2; + static long _mono; + static bool _pending; + + public static bool Active { get; private set; } + + public static void Begin(float thresholdMs) + { + Cancel(); + Current.Clear(); + Frames.Clear(); + _threshold = thresholdMs; + _frame = 0; + _gc0 = GC.CollectionCount(0); + _gc1 = GC.CollectionCount(1); + _gc2 = GC.CollectionCount(2); + _mono = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); + _pending = false; + Active = true; + _driverObject = new GameObject("S3 Sparse Hitch Sampler"); + _driverObject.hideFlags = HideFlags.HideAndDontSave; + UnityEngine.Object.DontDestroyOnLoad(_driverObject); + _driverObject.AddComponent(); + } + + public static List End() + { + Active = false; + DestroyDriver(); + Current.Clear(); + _pending = false; + return new List(Frames); + } + + public static void Cancel() + { + Active = false; + DestroyDriver(); + Current.Clear(); + Frames.Clear(); + _pending = false; + } + + public static void Record(string id, long ticks, int calls = 1) + { + if (!Active || ticks <= Stopwatch.Frequency / 10_000) return; + if (!Current.TryGetValue(id, out HitchProbeSample? sample)) + { + sample = new HitchProbeSample(); + Current[id] = sample; + } + sample.Calls += calls; + sample.Ticks += ticks; + if (ticks > sample.MaxTicks) sample.MaxTicks = ticks; + } + + public static void Advance(float frameMs) + { + if (!Active) return; + int gc0 = GC.CollectionCount(0); + int gc1 = GC.CollectionCount(1); + int gc2 = GC.CollectionCount(2); + long mono = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); + + if (_pending && frameMs > 0f && frameMs < 2000f) + { + var frame = new HitchFrameRecord + { + Phase = "manual-sparse", + Frame = _frame++, + FrameMs = frameMs, + Gc0 = gc0 - _gc0, + Gc1 = gc1 - _gc1, + Gc2 = gc2 - _gc2, + MonoDelta = mono - _mono, + CameraPosition = Camera.main != null + ? Camera.main.transform.position + : Vector3.zero, + }; + if (frameMs >= _threshold) + { + foreach (var pair in Current) + { + HitchProbeSample sample = pair.Value; + if (sample.Calls == 0) continue; + frame.Probes[pair.Key] = new HitchProbeSample + { + Calls = sample.Calls, + Ticks = sample.Ticks, + MaxTicks = sample.MaxTicks, + }; + } + } + Frames.Add(frame); + } + + foreach (HitchProbeSample sample in Current.Values) + { + sample.Calls = 0; + sample.Ticks = 0; + sample.MaxTicks = 0; + } + _gc0 = gc0; + _gc1 = gc1; + _gc2 = gc2; + _mono = mono; + _pending = true; + } + + static void DestroyDriver() + { + if (_driverObject == null) return; + UnityEngine.Object.Destroy(_driverObject); + _driverObject = null; + } +} + +[DefaultExecutionOrder(-31999)] +public sealed class SparseHitchFrameDriver : MonoBehaviour +{ + void Update() => + SparseHitchSampler.Advance(Time.unscaledDeltaTime * 1000f); +} diff --git a/mcp-tools/ToolsPlugin.cs b/mcp-tools/ToolsPlugin.cs new file mode 100644 index 0000000..1d218ec --- /dev/null +++ b/mcp-tools/ToolsPlugin.cs @@ -0,0 +1,1029 @@ +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)); +} diff --git a/mcp-tools/UnityMarkerProfiler.cs b/mcp-tools/UnityMarkerProfiler.cs new file mode 100644 index 0000000..9961ab9 --- /dev/null +++ b/mcp-tools/UnityMarkerProfiler.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using S3.Modules.Profiler; +using Unity.Profiling; +using Unity.Profiling.LowLevel.Unsafe; +using UnityEngine; + +namespace S3.Mcp.Tools; + +/// Capture-scoped Unity PlayerLoop and subsystem marker recorders. +internal static class UnityMarkerProfiler +{ + sealed class Entry + { + public string Id = ""; + public ProfilerRecorder Recorder; + } + + readonly struct Marker + { + public readonly ProfilerCategory Category; + public readonly string Name; + + public Marker(ProfilerCategory category, string name) + { + Category = category; + Name = name; + } + } + + static readonly Marker[] CandidateMarkers = + { + new(ProfilerCategory.Scripts, "BehaviourUpdate"), + new(ProfilerCategory.Scripts, "ScriptRunBehaviourUpdate"), + new(ProfilerCategory.Scripts, "ScriptRunBehaviourFixedUpdate"), + new(ProfilerCategory.Scripts, "ScriptRunBehaviourLateUpdate"), + new(ProfilerCategory.Scripts, "ScriptRunDelayedTasks"), + new(ProfilerCategory.Scripts, "CoroutinesDelayedCalls"), + new(ProfilerCategory.Scripts, "GC.Collect"), + new(ProfilerCategory.Scripts, "GC.Incremental.Collect"), + + new(ProfilerCategory.Internal, "PlayerLoop"), + new(ProfilerCategory.Internal, "EarlyUpdate"), + new(ProfilerCategory.Internal, "FixedUpdate"), + new(ProfilerCategory.Internal, "PreUpdate"), + new(ProfilerCategory.Internal, "Update"), + new(ProfilerCategory.Internal, "PreLateUpdate"), + new(ProfilerCategory.Internal, "PostLateUpdate"), + new(ProfilerCategory.Internal, "WaitForJobGroupID"), + new(ProfilerCategory.Internal, "JobHandle.Complete"), + new(ProfilerCategory.Internal, "Semaphore.WaitForSignal"), + new(ProfilerCategory.Internal, "WaitForTargetFPS"), + new(ProfilerCategory.Internal, "Gfx.WaitForPresentOnGfxThread"), + new(ProfilerCategory.Internal, "Gfx.PresentFrame"), + + new(ProfilerCategory.Loading, "PreloadManager.UpdatePreloading"), + new(ProfilerCategory.Loading, "Application.Integrate Assets in Background"), + new(ProfilerCategory.Loading, "AsyncUploadManager.Update"), + new(ProfilerCategory.Loading, "Resources.UnloadUnusedAssets"), + new(ProfilerCategory.Loading, "SceneManager.Update"), + + new(ProfilerCategory.Render, "UpdateAllRenderers"), + new(ProfilerCategory.Render, "UpdateRendererBoundingVolumes"), + new(ProfilerCategory.Render, "UpdateAllSkinnedMeshes"), + new(ProfilerCategory.Render, "Camera.Render"), + new(ProfilerCategory.Render, "CullScriptable"), + new(ProfilerCategory.Render, "RenderLoop.Draw"), + new(ProfilerCategory.Render, "BatchRendererGroup"), + + new(ProfilerCategory.Animation, "DirectorUpdateAnimationBegin"), + new(ProfilerCategory.Animation, "DirectorUpdateAnimationEnd"), + new(ProfilerCategory.Animation, "Animator.Update"), + new(ProfilerCategory.Physics, "Physics.Simulate"), + new(ProfilerCategory.Particles, "ParticleSystem.Update"), + new(ProfilerCategory.Particles, "ParticleSystem.ScheduleGeometryJobs"), + }; + + static readonly List Entries = new(); + static GameObject? _driverObject; + + public static int ActiveMarkers => Entries.Count; + + public static string Start() + { + Stop(); + var names = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var available = new List(); + try { ProfilerRecorderHandle.GetAvailable(available); } + catch { } + + for (int i = 0; i < available.Count && Entries.Count < 512; i++) + { + try + { + ProfilerRecorderDescription description = + ProfilerRecorderHandle.GetDescription(available[i]); + string name = description.Name; + if (description.UnitType != + ProfilerMarkerDataUnit.TimeNanoseconds) + continue; + AddRecorder(description.Category, name, names, seen); + } + catch { } + } + + // Some release players expose only a small built-in counter list. + // Probe known marker/category pairs to supplement that list. + for (int i = 0; + i < CandidateMarkers.Length && Entries.Count < 512; + i++) + { + Marker marker = CandidateMarkers[i]; + AddRecorder(marker.Category, marker.Name, names, seen); + } + + if (Entries.Count > 0) + { + _driverObject = new GameObject("S3 Deep Unity Marker Profiler"); + _driverObject.hideFlags = HideFlags.HideAndDontSave; + UnityEngine.Object.DontDestroyOnLoad(_driverObject); + _driverObject.AddComponent(); + } + return Entries.Count == 0 + ? "markers=0" + : $"available={available.Count} markers={Entries.Count} " + + $"[{string.Join(", ", names.GetRange(0, Math.Min(20, names.Count)))}" + + (names.Count > 20 ? ", ...]" : "]"); + } + + static void AddRecorder( + ProfilerCategory category, + string name, + List names, + HashSet seen) + { + string unique = category.Name + ":" + name; + if (!seen.Add(unique)) return; + try + { + ProfilerRecorder recorder = ProfilerRecorder.StartNew( + category, + name, + 1, + ProfilerRecorderOptions.StartImmediately | + ProfilerRecorderOptions.SumAllSamplesInFrame); + if (!recorder.Valid) + { + recorder.Dispose(); + return; + } + string id = "unity." + category.Name + ":" + name; + Entries.Add(new Entry { Id = id, Recorder = recorder }); + names.Add(unique); + } + catch { } + } + + public static void Stop() + { + if (_driverObject != null) + { + UnityEngine.Object.Destroy(_driverObject); + _driverObject = null; + } + for (int i = 0; i < Entries.Count; i++) + { + try { Entries[i].Recorder.Dispose(); } + catch { } + } + Entries.Clear(); + } + + public static void Sample() + { + if (!SparseHitchSampler.Active) return; + for (int i = 0; i < Entries.Count; i++) + { + Entry entry = Entries[i]; + long nanoseconds; + try { nanoseconds = entry.Recorder.LastValue; } + catch { continue; } + if (nanoseconds <= 0 || nanoseconds > 10_000_000_000L) + continue; + long ticks = (long)( + nanoseconds * (double)Stopwatch.Frequency / 1_000_000_000.0); + SparseHitchSampler.Record(entry.Id, ticks); + } + } +} + +[DefaultExecutionOrder(31900)] +public sealed class UnityMarkerFrameDriver : MonoBehaviour +{ + void LateUpdate() => UnityMarkerProfiler.Sample(); +} diff --git a/src/Main.cs b/src/Main.cs index 51533a2..9b8911a 100644 --- a/src/Main.cs +++ b/src/Main.cs @@ -39,6 +39,7 @@ public static class Main _registry.Register(new Modules.QuickActions.QuickActionsModule()); _registry.Register(new Modules.CarCards.CarCardsModule()); _registry.Register(new Modules.IndustryTags.IndustryTagsModule()); + _registry.Register(new Modules.Mcp.McpModule()); _registry.EnableConfigured(); ModConflicts.CheckAtLoad(); diff --git a/src/Modules/Mcp/McpConsole.cs b/src/Modules/Mcp/McpConsole.cs new file mode 100644 index 0000000..dbed93e --- /dev/null +++ b/src/Modules/Mcp/McpConsole.cs @@ -0,0 +1,51 @@ +using System; +using HarmonyLib; +using S3.Core; +using UI.Console; + +namespace S3.Modules.Mcp; + +[HarmonyPatch(typeof(ConsoleCommandHandler))] +[HarmonyPatch("_HandleSlashCommand")] +static class McpConsolePatch +{ + static bool Prefix(string[] comps, ref string __result) + { + if (comps.Length == 0 || !string.Equals(comps[0], "/s3mcp", StringComparison.OrdinalIgnoreCase)) + return true; + __result = McpConsole.Handle(comps); + return false; + } +} + +static class McpConsole +{ + internal static string Handle(string[] comps) + { + string sub = comps.Length >= 2 ? comps[1].ToLowerInvariant() : "status"; + var host = McpHost.Instance; + var s = McpModule.Settings; + + switch (sub) + { + case "help": + return "Usage: /s3mcp [status|token|reload|restart|help]"; + case "token": + return s.token; + case "reload": + if (host == null) return "MCP host not running (enable the module and restart)."; + return host.ReloadNow(restartServer: true); + case "restart": + if (host == null) return "MCP host not running."; + host.RequestRestart(); + return "restart queued"; + default: + if (host == null) + return "MCP host not running (enable MCP in S3 settings and restart)."; + return host.ListenSummary + "\n" + host.Url + "\ntoken=" + s.token + + "\nplugins=" + host.PluginSummary + + "\nobserve=" + s.observe + " control=" + s.control + " develop=" + s.develop + + "\nautoReload=" + s.autoReload; + } + } +} diff --git a/src/Modules/Mcp/McpGame.cs b/src/Modules/Mcp/McpGame.cs new file mode 100644 index 0000000..c1f558f --- /dev/null +++ b/src/Modules/Mcp/McpGame.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using Game; +using HarmonyLib; +using Helpers; +using Model; +using Model.Physics; +using S3.Core; +using S3.Mcp; +using S3.Modules.PhysicsOptimizer; +using UI.Console; +using UI.Menu; +using UnityEngine; + +namespace S3.Modules.Mcp; + +sealed class McpGame : IMcpGame +{ + static PersistentLoader? _loader; + static GameObject? _loadingScreen; + + public bool InPlay => ComputeInPlay(); + + public string StatusText() + { + var s = McpModule.Settings; + var sb = new StringBuilder(); + sb.AppendLine("s3-mcp"); + sb.AppendLine($"inPlay={InPlay}"); + sb.AppendLine($"observe={s.observe} control={s.control} develop={s.develop}"); + sb.AppendLine($"autoReload={s.autoReload}"); + var host = McpHost.Instance; + if (host != null) + { + sb.AppendLine($"listen={host.ListenSummary}"); + sb.AppendLine($"url={host.Url}"); + sb.AppendLine($"plugins={host.PluginSummary}"); + } + try + { + var car = TrainController.Shared?.SelectedCar; + if (car != null) + sb.AppendLine($"selected={car.DisplayName} id={car.id}"); + else + sb.AppendLine("selected=(none)"); + } + catch { sb.AppendLine("selected=(error)"); } + + Camera? cam = Camera.main; + try { MainCameraHelper.TryGetIfNeeded(ref cam); } + catch { } + if (cam != null) + { + Vector3 p = cam.transform.position; + sb.AppendLine($"cam={cam.name} pos=({p.x:0.0},{p.y:0.0},{p.z:0.0}) fov={cam.fieldOfView:0.0}"); + } + else sb.AppendLine("cam=(none)"); + return sb.ToString().TrimEnd(); + } + + public string ModulesText() + { + var sb = new StringBuilder(); + var reg = Main.Registry; + if (reg == null) + return "no registry"; + foreach (IModule m in reg.Modules) + sb.AppendLine( + $"{m.Id,-16} active={reg.IsActive(m),-5} configured={m.Enabled,-5} {m.DisplayName}"); + return sb.ToString().TrimEnd(); + } + + public string LogTail(int lines) => string.Join("\n", Log.Tail(lines)); + + public string ConsistsText(int limit) + { + if (limit < 1) limit = 1; + if (limit > 200) limit = 200; + var tc = TrainController.Shared; + if (tc == null) return "not in play"; + Car? selected = null; + try { selected = tc.SelectedCar; } + catch { } + var seenSets = new HashSet(); + var seenLoose = new HashSet(); + var sb = new StringBuilder(); + int n = 0; + try + { + foreach (Car car in tc.Cars) + { + if (car == null) continue; + IntegrationSet? set = car.set; + if (set != null) + { + if (!seenSets.Add(set.Id)) continue; + bool sel = false; + try { sel = selected != null && selected.set != null && selected.set.Id == set.Id; } + catch { } + string lead = car.DisplayName; + sb.AppendLine($"set #{set.Id} lead={lead} cars={set.NumberOfCars}{(sel ? " SELECTED" : "")}"); + } + else + { + if (!seenLoose.Add(car.id)) continue; + bool sel = selected != null && selected.id == car.id; + sb.AppendLine($"{car.id} {car.DisplayName}{(sel ? " SELECTED" : "")}"); + } + n++; + if (n >= limit) break; + } + } + catch (Exception e) + { + sb.AppendLine("error: " + e.Message); + } + sb.AppendLine($"listed={n}"); + return sb.ToString().TrimEnd(); + } + + public string SelectCar(string id) + { + var tc = TrainController.Shared; + if (tc == null) return "not in play"; + if (string.IsNullOrEmpty(id)) return "id required"; + try + { + if (!tc.TryGetCarForId(id, out Car car) || car == null) + return $"no car {id}"; + tc.SelectedCar = car; + return $"selected {car.DisplayName} ({car.id})"; + } + catch (Exception e) + { + return "select failed: " + e.Message; + } + } + + public string FreezeSelected(bool freeze) + { + Car? car = null; + try { car = TrainController.Shared?.SelectedCar; } + catch { } + if (car == null) return "no car selected"; + IntegrationSet? set = car.set; + if (set == null) return $"{car.id} has no IntegrationSet"; + if (freeze) + { + set.SetVelocity(0f, set.Cars.ToList()); + bool added = ConsistFreezer.Freeze(set.Id); + return added + ? $"froze set #{set.Id} ({set.NumberOfCars} cars)" + : $"set #{set.Id} already frozen"; + } + bool removed = ConsistFreezer.Unfreeze(set.Id); + return removed + ? $"unfroze set #{set.Id}" + : $"set #{set.Id} was not frozen"; + } + + public string RunSlash(string command) + { + if (string.IsNullOrWhiteSpace(command)) + return "empty command"; + command = command.Trim(); + if (!command.StartsWith("/")) + command = "/" + command; + + var handler = UnityEngine.Object.FindObjectOfType(); + if (handler == null) + return "ConsoleCommandHandler not found (open a session first)."; + + string[] comps; + try + { + var tokenize = typeof(ConsoleCommandHandler).GetMethod( + "Tokenize", BindingFlags.NonPublic | BindingFlags.Static); + if (tokenize == null) + comps = command.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + else + { + var list = tokenize.Invoke(null, new object[] { command }) as List; + comps = list != null ? list.ToArray() : Array.Empty(); + } + } + catch + { + comps = command.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + } + + if (comps.Length == 0) + return "empty command"; + + try + { + string? text = Traverse.Create(handler) + .Method("_HandleSlashCommand", new[] { typeof(string[]) }, new object[] { comps }) + .GetValue(); + return string.IsNullOrEmpty(text) ? "(no output)" : text; + } + catch (Exception e) + { + return "slash failed: " + e.Message; + } + } + + public string Screenshot(int maxWidth) + { + if (maxWidth < 160) maxWidth = 160; + if (maxWidth > 2560) maxWidth = 2560; + + Camera? cam = Camera.main; + try { MainCameraHelper.TryGetIfNeeded(ref cam); } + catch { } + if (cam == null) + return "no camera"; + + int w = Math.Max(160, cam.pixelWidth); + int h = Math.Max(90, cam.pixelHeight); + if (w > maxWidth) + { + float scale = maxWidth / (float)w; + w = maxWidth; + h = Math.Max(90, (int)(h * scale)); + } + + RenderTexture? old = cam.targetTexture; + var rt = RenderTexture.GetTemporary(w, h, 24); + Texture2D? tex = null; + try + { + cam.targetTexture = rt; + cam.Render(); + RenderTexture.active = rt; + tex = new Texture2D(w, h, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(0, 0, w, h), 0, 0); + tex.Apply(); + byte[] png = tex.EncodeToPNG(); + string path = Path.Combine(Main.ModEntry.Path, "mcp-shot.png"); + File.WriteAllBytes(path, png); + return path; + } + catch (Exception e) + { + return "screenshot failed: " + e.Message; + } + finally + { + cam.targetTexture = old; + RenderTexture.active = null; + RenderTexture.ReleaseTemporary(rt); + if (tex != null) UnityEngine.Object.Destroy(tex); + } + } + + static bool ComputeInPlay() + { + try + { + if (TrainController.Shared == null) return false; + if (SceneDescriptor.MainMenu.IsLoaded) return false; + if (!SceneDescriptor.GameUI.IsLoaded) return false; + return !LoadingScreenVisible(); + } + catch { return false; } + } + + static bool LoadingScreenVisible() + { + try + { + if (_loadingScreen != null) + return _loadingScreen.activeInHierarchy; + if (_loader == null) + _loader = UnityEngine.Object.FindObjectOfType(); + if (_loader == null) return false; + _loadingScreen = Traverse.Create(_loader).Field("loadingScreen").GetValue(); + return _loadingScreen != null && _loadingScreen.activeInHierarchy; + } + catch { return false; } + } +} diff --git a/src/Modules/Mcp/McpHost.cs b/src/Modules/Mcp/McpHost.cs new file mode 100644 index 0000000..031adf9 --- /dev/null +++ b/src/Modules/Mcp/McpHost.cs @@ -0,0 +1,362 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Threading; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using S3.Core; +using S3.Mcp; +using UnityEngine; + +namespace S3.Modules.Mcp; + +sealed class McpWork +{ + public JObject Request = null!; + public JObject? Response; + public ManualResetEventSlim Done = new(false); +} + +sealed class McpHost : MonoBehaviour +{ + public static McpHost? Instance { get; private set; } + + readonly ConcurrentQueue _queue = new(); + readonly McpRegistry _registry = new(); + readonly McpGame _game = new(); + + McpPlugins _plugins = null!; + McpServer? _server; + FileSystemWatcher? _watch; + int _watchPulse; + int _suppressWatchUntil; + bool _reloadQueued; + bool _restartQueued; + string _lastError = ""; + + public string ListenSummary + { + get + { + if (_server == null || !_server.Running) + return "down" + (string.IsNullOrEmpty(_lastError) ? "" : " (" + _lastError + ")"); + return "127.0.0.1:" + _server.BoundPort; + } + } + + public string Url => + _server != null && _server.Running + ? "http://127.0.0.1:" + _server.BoundPort + "/mcp" + : ""; + + public string PluginSummary => _plugins != null ? _plugins.Summary : "(init)"; + + public string LastError => _lastError; + + void Awake() + { + Instance = this; + _plugins = new McpPlugins(_registry, _game); + RegisterHostTools(); + StartServer(); + ReloadTools(restartServer: false); + StartWatcher(); + } + + void OnDestroy() + { + if (Instance == this) Instance = null; + StopWatcher(); + _plugins?.StopAll(); + _server?.Stop(); + while (_queue.TryDequeue(out var work)) + { + work.Response = ProtocolError(work.Request, "host destroyed"); + work.Done.Set(); + } + } + + void Update() + { + DrainQueue(); + MaybeWatchReload(); + if (_reloadQueued) + { + _reloadQueued = false; + bool restart = _restartQueued; + _restartQueued = false; + ReloadTools(restart); + } + else if (_restartQueued) + { + _restartQueued = false; + StartServer(); + } + } + + void MaybeWatchReload() + { + if (_watchPulse == 0) return; + int now = Environment.TickCount; + if (now - _watchPulse < 800) return; + _watchPulse = 0; + if (!McpModule.Settings.autoReload) return; + RequestReload(restartServer: true); + } + + public void RequestReload(bool restartServer) + { + _reloadQueued = true; + if (restartServer) _restartQueued = true; + } + + public void RequestRestart() => _restartQueued = true; + + public string ReloadNow(bool restartServer) + { + return ReloadTools(restartServer); + } + + JObject? DispatchOnMain(JObject request) + { + var work = new McpWork { Request = request }; + _queue.Enqueue(work); + int timeout = Math.Max(1000, McpModule.Settings.callTimeoutMs); + if (!work.Done.Wait(timeout)) + return ProtocolError(request, "timed out waiting for Unity main thread (" + timeout + "ms)"); + return work.Response; + } + + void DrainQueue() + { + int n = 0; + while (n < 8 && _queue.TryDequeue(out var work)) + { + n++; + try { work.Response = McpProtocol.Handle(work.Request, _registry); } + catch (Exception e) + { + Log.Error("[mcp] dispatch: " + e); + work.Response = ProtocolError(work.Request, e.Message); + } + work.Done.Set(); + } + } + + void StartServer() + { + _server?.Stop(); + var s = McpModule.Settings; + _server = new McpServer(s.token ?? "", DispatchOnMain); + if (!_server.Start(s.port)) + { + _lastError = _server.LastError; + return; + } + _lastError = ""; + WriteConnectFile(); + } + + string ReloadTools(bool restartServer) + { + _suppressWatchUntil = Environment.TickCount + 1500; + _watchPulse = 0; + var sb = new System.Text.StringBuilder(); + sb.AppendLine(_plugins.ReloadOfficial()); + if (McpModule.Settings.develop) + sb.AppendLine(_plugins.ReloadExtras()); + if (restartServer) + StartServer(); + string text = sb.ToString().TrimEnd(); + Log.Info("[mcp] reload:\n" + text); + return text; + } + + void StartWatcher() + { + StopWatcher(); + try + { + Directory.CreateDirectory(_plugins.PluginDir); + _watch = new FileSystemWatcher(_plugins.PluginDir); + _watch.Filter = "*.dll"; + _watch.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName | NotifyFilters.CreationTime; + _watch.Changed += OnWatch; + _watch.Created += OnWatch; + _watch.Renamed += OnWatch; + _watch.EnableRaisingEvents = true; + } + catch (Exception e) + { + Log.Warn("[mcp] watcher: " + e.Message); + } + } + + void StopWatcher() + { + if (_watch == null) return; + try + { + _watch.EnableRaisingEvents = false; + _watch.Changed -= OnWatch; + _watch.Created -= OnWatch; + _watch.Renamed -= OnWatch; + _watch.Dispose(); + } + catch { } + _watch = null; + } + + void OnWatch(object sender, FileSystemEventArgs e) + { + if (!McpModule.Settings.autoReload) return; + int now = Environment.TickCount; + if (now - _suppressWatchUntil < 0) return; + string name = Path.GetFileName(e.Name ?? e.FullPath); + bool official = string.Equals(name, McpPlugins.OfficialFileName, StringComparison.OrdinalIgnoreCase); + bool extra = McpModule.Settings.develop && name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase); + if (!official && !extra) return; + _watchPulse = now; + } + + void WriteConnectFile() + { + try + { + var obj = new JObject + { + ["url"] = Url, + ["token"] = McpModule.Settings.token, + ["port"] = _server?.BoundPort ?? McpModule.Settings.port, + ["protocol"] = "mcp-streamable-http", + }; + File.WriteAllText( + Path.Combine(Main.ModEntry.Path, "mcp-connect.json"), + obj.ToString(Formatting.Indented)); + } + catch (Exception e) + { + Log.Warn("[mcp] connect file: " + e.Message); + } + } + + void RegisterHostTools() + { + AddHost("status", + "Railroader S3 MCP host status: in-play, selected car, gates, listen URL, loaded plugins.", + "{}", + _ => McpToolResult.Ok(_game.StatusText())); + + AddHost("mcp_reload", + "Reload S3.Mcp.Tools.dll (and extra plugins if Develop is on). Restarts the HTTP listener by default so Cursor re-fetches tools. Use this after dist/build-mcp-tools.ps1.", + "{\"restart\":{\"type\":\"boolean\",\"description\":\"Bounce the HTTP server (default true).\"}}", + args => + { + bool restart = args["restart"] == null || args["restart"]!.Value() != false; + if (restart) + { + RequestReload(restartServer: true); + return McpToolResult.Ok("reload+restart queued (listener will bounce after this call)"); + } + return McpToolResult.Ok(ReloadNow(false)); + }); + + AddHost("mcp_restart", + "Restart the localhost MCP HTTP listener without reloading plugin DLLs.", + "{}", + _ => + { + RequestRestart(); + return McpToolResult.Ok("restart queued"); + }); + + AddHost("plugin_list", + "List loaded MCP plugins and DLLs in Mods/S3/plugins.", + "{}", + _ => + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("loaded: " + _plugins.Summary); + try + { + Directory.CreateDirectory(_plugins.PluginDir); + foreach (string f in Directory.GetFiles(_plugins.PluginDir, "*.dll")) + sb.AppendLine("file: " + Path.GetFileName(f)); + } + catch (Exception e) { sb.AppendLine(e.Message); } + return McpToolResult.Ok(sb.ToString().TrimEnd()); + }); + + AddHost("plugin_load", + "Load or reload a plugin DLL from Mods/S3/plugins. Develop gate required for names other than S3.Mcp.Tools.dll.", + "{\"name\":{\"type\":\"string\",\"description\":\"DLL file name. Omit to reload official tools.\"}}", + args => + { + string name = args["name"]?.Value() ?? ""; + if (string.IsNullOrEmpty(name) || + string.Equals(name, McpPlugins.OfficialFileName, StringComparison.OrdinalIgnoreCase)) + return McpToolResult.Ok(ReloadNow(restartServer: false)); + if (!McpModule.Settings.develop) + return McpToolResult.Fail("Develop gate is off"); + return McpToolResult.Ok(_plugins.LoadNamed(name)); + }); + + AddHost("plugin_stop", + "Stop a loaded plugin by id and drop its tools.", + "{\"id\":{\"type\":\"string\"}}", + args => McpToolResult.Ok(_plugins.Stop(args["id"]?.Value() ?? ""))); + + AddHost("console", + "Run an in-game slash command on the Unity main thread (e.g. /s3ind dump, /s3wq dump, /rpf dump).", + "{\"command\":{\"type\":\"string\"}}", + args => + { + string cmd = args["command"]?.Value() ?? ""; + return McpToolResult.Ok(_game.RunSlash(cmd)); + }); + + AddHost("log_tail", + "Last lines of the S3 log ring buffer.", + "{\"lines\":{\"type\":\"integer\"}}", + args => + { + int lines = args["lines"]?.Value() ?? 80; + return McpToolResult.Ok(_game.LogTail(lines)); + }); + + AddHost("modules", + "S3 module id, enabled flag, and display name.", + "{}", + _ => McpToolResult.Ok(_game.ModulesText())); + } + + void AddHost(string name, string description, string propsJson, Func handler) + { + JObject props; + try { props = JObject.Parse("{\"type\":\"object\",\"properties\":" + propsJson + ",\"additionalProperties\":false}"); } + catch { props = JObject.Parse("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}"); } + if (propsJson == "{}") + props = JObject.Parse("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}"); + _registry.Add(new McpToolEntry + { + Name = name, + Description = description, + InputSchema = props, + Gate = McpGate.Observe, + Source = "host", + Handler = handler, + }); + } + + static JObject ProtocolError(JObject request, string message) + { + var obj = new JObject + { + ["jsonrpc"] = "2.0", + ["error"] = new JObject { ["code"] = -32603, ["message"] = message }, + }; + if (request["id"] != null && request["id"]!.Type != JTokenType.Null) + obj["id"] = request["id"]; + return obj; + } +} diff --git a/src/Modules/Mcp/McpModule.cs b/src/Modules/Mcp/McpModule.cs new file mode 100644 index 0000000..00fb20d --- /dev/null +++ b/src/Modules/Mcp/McpModule.cs @@ -0,0 +1,66 @@ +using System; +using HarmonyLib; +using S3.Core; +using UnityEngine; + +namespace S3.Modules.Mcp; + +public sealed class McpModule : IModule +{ + private const string SettingsFile = "S3.mcp.json"; + + public static McpSettings Settings { get; private set; } = new(); + + private static Harmony? _harmony; + private static GameObject? _hostGo; + + public McpModule() + { + Settings = SettingsStore.Load(SettingsFile); + if (string.IsNullOrEmpty(Settings.token)) + { + Settings.token = "s3-local-dev"; + SettingsStore.Save(SettingsFile, Settings); + } + if (Settings.port <= 0 || Settings.port >= 65536) + Settings.port = 18765; + if (Settings.callTimeoutMs < 1000) + Settings.callTimeoutMs = 8000; + } + + public string Id => "mcp"; + public string DisplayName => "MCP"; + public string Description => + "Localhost MCP server so a coding agent can inspect the live game and, with gates on, " + + "control it or load plugin DLLs. Tools reload without quitting. Console: /s3mcp"; + + public bool Enabled + { + get => Settings.enabled; + set => Settings.enabled = value; + } + + public void OnEnable() + { + _harmony = new Harmony("S3.mcp"); + try { _harmony.CreateClassProcessor(typeof(McpConsolePatch)).Patch(); } + catch (Exception e) { Log.Error($"[mcp] console patch failed: {e.Message}"); } + + _hostGo = new GameObject("[S3] McpHost"); + UnityEngine.Object.DontDestroyOnLoad(_hostGo); + _hostGo.AddComponent(); + } + + public void OnDisable() + { + _harmony?.UnpatchAll("S3.mcp"); + _harmony = null; + if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo); + _hostGo = null; + } + + public void SaveSettings() => Persist(); + internal static void Persist() => SettingsStore.Save(SettingsFile, Settings); + + public void DrawSettings() => McpSettingsUI.Draw(); +} diff --git a/src/Modules/Mcp/McpPlugins.cs b/src/Modules/Mcp/McpPlugins.cs new file mode 100644 index 0000000..cfd4bee --- /dev/null +++ b/src/Modules/Mcp/McpPlugins.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Newtonsoft.Json.Linq; +using S3.Core; +using S3.Mcp; + +namespace S3.Modules.Mcp; + +sealed class LoadedPlugin +{ + public string Id = ""; + public string Path = ""; + public IAgentPlugin Instance = null!; +} + +sealed class McpPlugins +{ + public const string OfficialFileName = "S3.Mcp.Tools.dll"; + + readonly McpRegistry _registry; + readonly IMcpGame _game; + readonly List _loaded = new(); + + public McpPlugins(McpRegistry registry, IMcpGame game) + { + _registry = registry; + _game = game; + } + + public string PluginDir => Path.Combine(Main.ModEntry.Path, "plugins"); + + public string OfficialPath => Path.Combine(PluginDir, OfficialFileName); + + public string Summary + { + get + { + if (_loaded.Count == 0) return "(none)"; + var ids = new string[_loaded.Count]; + for (int i = 0; i < _loaded.Count; i++) + ids[i] = _loaded[i].Id; + return string.Join(", ", ids); + } + } + + public IReadOnlyList Loaded => _loaded; + + public string ReloadOfficial() + { + UnloadById("s3.mcp.tools"); + UnloadById("tools"); + Directory.CreateDirectory(PluginDir); + if (!File.Exists(OfficialPath)) + return "no " + OfficialFileName + " in " + PluginDir; + return LoadFile(OfficialPath); + } + + public string ReloadExtras() + { + Directory.CreateDirectory(PluginDir); + var sb = new System.Text.StringBuilder(); + foreach (string path in Directory.GetFiles(PluginDir, "*.dll")) + { + string name = Path.GetFileName(path); + if (string.Equals(name, OfficialFileName, StringComparison.OrdinalIgnoreCase)) + continue; + if (name.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase)) + continue; + UnloadByFile(path); + sb.AppendLine(LoadFile(path)); + } + string text = sb.ToString().TrimEnd(); + return string.IsNullOrEmpty(text) ? "no extra plugins" : text; + } + + public string LoadNamed(string name) + { + Directory.CreateDirectory(PluginDir); + string path = name; + if (!Path.IsPathRooted(path)) + path = Path.Combine(PluginDir, name); + if (!path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + path += ".dll"; + if (!File.Exists(path)) + return "not found: " + path; + UnloadByFile(path); + return LoadFile(path); + } + + public string Stop(string id) + { + if (string.IsNullOrEmpty(id)) + return "id required"; + if (!UnloadById(id)) + return "not loaded: " + id; + return "stopped " + id; + } + + public void StopAll() + { + for (int i = _loaded.Count - 1; i >= 0; i--) + StopOne(_loaded[i]); + _loaded.Clear(); + } + + string LoadFile(string path) + { + byte[] bytes; + try { bytes = File.ReadAllBytes(path); } + catch (Exception e) { return "read failed " + path + ": " + e.Message; } + + Assembly asm; + try { asm = Assembly.Load(bytes); } + catch (Exception e) { return "Assembly.Load failed " + Path.GetFileName(path) + ": " + e.Message; } + + Type? pluginType = null; + try + { + foreach (Type t in asm.GetTypes()) + { + if (t == null || t.IsAbstract || t.IsInterface) continue; + if (typeof(IAgentPlugin).IsAssignableFrom(t)) + { + pluginType = t; + break; + } + } + } + catch (ReflectionTypeLoadException e) + { + return "types failed in " + Path.GetFileName(path) + ": " + (e.LoaderExceptions?.Length > 0 ? e.LoaderExceptions[0]?.Message : e.Message); + } + + if (pluginType == null) + return Path.GetFileName(path) + " has no IAgentPlugin"; + + IAgentPlugin instance; + try { instance = (IAgentPlugin)Activator.CreateInstance(pluginType)!; } + catch (Exception e) { return "create failed: " + e.Message; } + + string id = instance.Id; + if (string.IsNullOrEmpty(id)) + id = pluginType.Name; + UnloadById(id); + + var api = new McpApi(_registry, _game, id); + try { instance.Start(api); } + catch (Exception e) + { + try { instance.Stop(); } + catch { } + return "Start failed (" + id + "): " + e.Message; + } + + _loaded.Add(new LoadedPlugin { Id = id, Path = path, Instance = instance }); + Log.Info("[mcp] loaded plugin " + id + " from " + Path.GetFileName(path)); + return "loaded " + id + " (" + Path.GetFileName(path) + ")"; + } + + bool UnloadById(string id) + { + bool any = false; + for (int i = _loaded.Count - 1; i >= 0; i--) + { + if (!string.Equals(_loaded[i].Id, id, StringComparison.OrdinalIgnoreCase)) + continue; + StopOne(_loaded[i]); + _loaded.RemoveAt(i); + any = true; + } + return any; + } + + void UnloadByFile(string path) + { + for (int i = _loaded.Count - 1; i >= 0; i--) + { + if (!string.Equals(_loaded[i].Path, path, StringComparison.OrdinalIgnoreCase)) + continue; + StopOne(_loaded[i]); + _loaded.RemoveAt(i); + } + } + + void StopOne(LoadedPlugin p) + { + _registry.ClearSource(p.Id); + try { p.Instance.Stop(); } + catch (Exception e) { Log.Warn("[mcp] Stop " + p.Id + ": " + e.Message); } + } +} + +sealed class McpApi : IMcpApi +{ + readonly McpRegistry _registry; + readonly string _sourceId; + readonly IMcpGame _game; + + public McpApi(McpRegistry registry, IMcpGame game, string sourceId) + { + _registry = registry; + _game = game; + _sourceId = sourceId; + } + + public bool Observe => McpModule.Settings.observe; + public bool Control => McpModule.Settings.control; + public bool Develop => McpModule.Settings.develop; + public IMcpGame Game => _game; + + public void Log(string msg) => S3.Core.Log.Info("[mcp:" + _sourceId + "] " + msg); + + public void RegisterTool( + string name, + string description, + string inputSchemaJson, + McpGate gate, + Func handler) + { + JObject schema; + try { schema = string.IsNullOrEmpty(inputSchemaJson) ? EmptySchema() : JObject.Parse(inputSchemaJson); } + catch { schema = EmptySchema(); } + + _registry.Add(new McpToolEntry + { + Name = name, + Description = description ?? "", + InputSchema = schema, + Gate = gate, + Source = _sourceId, + Handler = handler, + }); + } + + static JObject EmptySchema() => new() + { + ["type"] = "object", + ["properties"] = new JObject(), + ["additionalProperties"] = false, + }; +} diff --git a/src/Modules/Mcp/McpProtocol.cs b/src/Modules/Mcp/McpProtocol.cs new file mode 100644 index 0000000..a662f99 --- /dev/null +++ b/src/Modules/Mcp/McpProtocol.cs @@ -0,0 +1,183 @@ +using System; +using System.IO; +using Newtonsoft.Json.Linq; +using S3.Core; +using S3.Mcp; + +namespace S3.Modules.Mcp; + +static class McpProtocol +{ + public const string ServerName = "s3"; + + public static JObject? Handle(JObject req, McpRegistry registry) + { + string method = req["method"]?.Value() ?? ""; + JToken? id = req["id"]; + bool notification = id == null || id.Type == JTokenType.Null; + JObject args = req["params"] as JObject ?? new JObject(); + + try + { + if (method == "initialize") + return Result(id, Initialize(args)); + if (method == "notifications/initialized" || method == "initialized") + return notification ? null : Result(id, new JObject()); + if (method == "ping") + return Result(id, new JObject()); + if (method == "tools/list") + return Result(id, ToolsList(registry)); + if (method == "tools/call") + return Result(id, ToolsCall(args, registry)); + + if (notification) + return null; + return Error(id, -32601, "unknown method " + method); + } + catch (Exception e) + { + Log.Error("[mcp] " + method + ": " + e); + if (notification) return null; + return Error(id, -32603, e.Message); + } + } + + static JObject Initialize(JObject args) + { + string clientVer = args["protocolVersion"]?.Value() ?? "2024-11-05"; + string ver = clientVer; + if (ver != "2024-11-05" && ver != "2025-03-26" && ver != "2025-06-18") + ver = "2024-11-05"; + + string version = "s3"; + try { version = Main.ModEntry.Version.ToString(); } + catch { } + + return new JObject + { + ["protocolVersion"] = ver, + ["capabilities"] = new JObject + { + ["tools"] = new JObject { ["listChanged"] = true }, + }, + ["serverInfo"] = new JObject + { + ["name"] = ServerName, + ["version"] = version, + }, + }; + } + + static JObject ToolsList(McpRegistry registry) + { + var tools = new JArray(); + foreach (McpToolEntry t in registry.Visible()) + { + tools.Add(new JObject + { + ["name"] = t.Name, + ["description"] = t.Description, + ["inputSchema"] = t.InputSchema, + }); + } + return new JObject { ["tools"] = tools }; + } + + static JObject ToolsCall(JObject args, McpRegistry registry) + { + string name = args["name"]?.Value() ?? ""; + JObject callArgs = args["arguments"] as JObject ?? new JObject(); + var entry = registry.Find(name); + if (entry == null) + return CallError("unknown tool " + name); + + var s = McpModule.Settings; + if (entry.Source != "host") + { + if (entry.Gate == McpGate.Observe && !s.observe) + return CallError("Observe gate is off"); + if (entry.Gate == McpGate.Control && !s.control) + return CallError("Control gate is off"); + if (entry.Gate == McpGate.Develop && !s.develop) + return CallError("Develop gate is off"); + } + + McpToolResult result; + try { result = entry.Handler(callArgs) ?? McpToolResult.Fail("null result"); } + catch (Exception e) + { + Log.Error("[mcp] tool " + name + ": " + e); + result = McpToolResult.Fail(e.Message); + } + + var content = new JArray + { + new JObject { ["type"] = "text", ["text"] = result.Text ?? "" }, + }; + + if (!string.IsNullOrEmpty(result.ImagePath) && File.Exists(result.ImagePath)) + { + try + { + byte[] bytes = File.ReadAllBytes(result.ImagePath); + if (bytes.Length > 0 && bytes.Length < 1_800_000) + { + content.Add(new JObject + { + ["type"] = "image", + ["mimeType"] = "image/png", + ["data"] = Convert.ToBase64String(bytes), + }); + } + } + catch (Exception e) + { + content.Add(new JObject + { + ["type"] = "text", + ["text"] = "\n(image attach failed: " + e.Message + ")", + }); + } + } + + return new JObject + { + ["content"] = content, + ["isError"] = result.IsError, + }; + } + + static JObject CallError(string message) => + new() + { + ["content"] = new JArray + { + new JObject { ["type"] = "text", ["text"] = message }, + }, + ["isError"] = true, + }; + + static JObject Result(JToken? id, JToken result) + { + var obj = new JObject + { + ["jsonrpc"] = "2.0", + ["result"] = result, + }; + if (id != null && id.Type != JTokenType.Null) + obj["id"] = id; + return obj; + } + + static JObject Error(JToken? id, int code, string message) + { + var obj = new JObject + { + ["jsonrpc"] = "2.0", + ["error"] = new JObject { ["code"] = code, ["message"] = message }, + }; + if (id != null && id.Type != JTokenType.Null) + obj["id"] = id; + return obj; + } +} diff --git a/src/Modules/Mcp/McpPublic.cs b/src/Modules/Mcp/McpPublic.cs new file mode 100644 index 0000000..b92cff5 --- /dev/null +++ b/src/Modules/Mcp/McpPublic.cs @@ -0,0 +1,63 @@ +using System; +using Newtonsoft.Json.Linq; + +namespace S3.Mcp; + +/// +/// Public contract compiled into S3.dll. Reloadable tool assemblies +/// (S3.Mcp.Tools.dll and extra plugins) implement +/// and talk to the host only through these types. +/// +public enum McpGate +{ + Observe = 0, + Control = 1, + Develop = 2, +} + +public sealed class McpToolResult +{ + public string Text = ""; + public string? ImagePath; + public bool IsError; + + public static McpToolResult Ok(string text) => new() { Text = text ?? "" }; + public static McpToolResult Fail(string text) => new() { Text = text ?? "error", IsError = true }; + public static McpToolResult Image(string path, string caption) => + new() { Text = caption ?? "", ImagePath = path }; +} + +public interface IAgentPlugin +{ + string Id { get; } + void Start(IMcpApi api); + void Stop(); +} + +public interface IMcpApi +{ + bool Observe { get; } + bool Control { get; } + bool Develop { get; } + IMcpGame Game { get; } + void Log(string msg); + void RegisterTool( + string name, + string description, + string inputSchemaJson, + McpGate gate, + Func handler); +} + +public interface IMcpGame +{ + bool InPlay { get; } + string StatusText(); + string RunSlash(string command); + string Screenshot(int maxWidth); + string LogTail(int lines); + string ModulesText(); + string ConsistsText(int limit); + string SelectCar(string id); + string FreezeSelected(bool freeze); +} diff --git a/src/Modules/Mcp/McpRegistry.cs b/src/Modules/Mcp/McpRegistry.cs new file mode 100644 index 0000000..4657e37 --- /dev/null +++ b/src/Modules/Mcp/McpRegistry.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using S3.Mcp; + +namespace S3.Modules.Mcp; + +sealed class McpToolEntry +{ + public string Name = ""; + public string Description = ""; + public JObject InputSchema = new(); + public McpGate Gate; + public string Source = "host"; + public Func Handler = _ => McpToolResult.Fail("no handler"); +} + +sealed class McpRegistry +{ + readonly List _tools = new(); + + public IReadOnlyList All => _tools; + + public void ClearSource(string source) + { + for (int i = _tools.Count - 1; i >= 0; i--) + if (_tools[i].Source == source) + _tools.RemoveAt(i); + } + + public void Add(McpToolEntry entry) + { + for (int i = _tools.Count - 1; i >= 0; i--) + if (_tools[i].Name == entry.Name) + _tools.RemoveAt(i); + _tools.Add(entry); + } + + public McpToolEntry? Find(string name) + { + for (int i = 0; i < _tools.Count; i++) + if (_tools[i].Name == name) + return _tools[i]; + return null; + } + + public List Visible() + { + var s = McpModule.Settings; + var list = new List(); + for (int i = 0; i < _tools.Count; i++) + { + var t = _tools[i]; + if (t.Source == "host") { list.Add(t); continue; } + if (t.Gate == McpGate.Observe && !s.observe) continue; + if (t.Gate == McpGate.Control && !s.control) continue; + if (t.Gate == McpGate.Develop && !s.develop) continue; + list.Add(t); + } + return list; + } +} diff --git a/src/Modules/Mcp/McpServer.cs b/src/Modules/Mcp/McpServer.cs new file mode 100644 index 0000000..6d3351b --- /dev/null +++ b/src/Modules/Mcp/McpServer.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using Newtonsoft.Json.Linq; +using S3.Core; + +namespace S3.Modules.Mcp; + +sealed class McpServer +{ + readonly Func _dispatch; + readonly string _token; + + TcpListener? _listener; + Thread? _thread; + volatile bool _run; + + public int BoundPort { get; private set; } + public string LastError { get; private set; } = ""; + public bool Running => _run; + + public McpServer(string token, Func dispatch) + { + _token = token ?? ""; + _dispatch = dispatch; + } + + public bool Start(int preferredPort) + { + Stop(); + Exception? last = null; + int port = preferredPort; + for (int i = 0; i < 5; i++) + { + try + { + var listener = new TcpListener(IPAddress.Loopback, port + i); + listener.Start(); + _listener = listener; + BoundPort = port + i; + _run = true; + LastError = ""; + _thread = new Thread(AcceptLoop) { IsBackground = true, Name = "S3-MCP" }; + _thread.Start(); + Log.Info($"[mcp] listening on 127.0.0.1:{BoundPort}"); + return true; + } + catch (Exception e) + { + last = e; + } + } + LastError = last?.Message ?? "bind failed"; + Log.Error("[mcp] bind failed: " + LastError); + return false; + } + + public void Stop() + { + _run = false; + try { _listener?.Stop(); } + catch { } + _listener = null; + if (_thread != null && _thread.IsAlive) + { + if (!_thread.Join(500)) + try { _thread.Interrupt(); } + catch { } + } + _thread = null; + } + + void AcceptLoop() + { + while (_run) + { + TcpClient? client = null; + try + { + client = _listener?.AcceptTcpClient(); + } + catch (SocketException) + { + if (!_run) break; + continue; + } + catch (ObjectDisposedException) + { + break; + } + catch (Exception e) + { + if (_run) Log.Warn("[mcp] accept: " + e.Message); + continue; + } + + if (client == null) continue; + TcpClient captured = client; + ThreadPool.QueueUserWorkItem(_ => HandleClient(captured)); + } + } + + void HandleClient(TcpClient client) + { + try + { + client.NoDelay = true; + client.ReceiveTimeout = 20000; + client.SendTimeout = 20000; + using (client) + using (NetworkStream stream = client.GetStream()) + { + for (int n = 0; n < 32 && _run; n++) + { + if (!TryReadHttp(stream, out string method, out string path, out var headers, out byte[] body)) + break; + + string conn = Header(headers, "connection"); + bool close = conn.IndexOf("close", StringComparison.OrdinalIgnoreCase) >= 0; + + HandleOne(stream, method, path, headers, body); + + if (close) break; + if (string.Equals(Header(headers, "http-version-close"), "1", StringComparison.Ordinal)) + break; + } + } + } + catch (Exception e) + { + if (_run) Log.Warn("[mcp] client: " + e.Message); + } + } + + void HandleOne(NetworkStream stream, string method, string path, Dictionary headers, byte[] body) + { + if (path != "/mcp" && path != "/health" && path != "/") + { + WriteHttp(stream, 404, "text/plain", Encoding.UTF8.GetBytes("not found")); + return; + } + + if (method == "GET") + { + WriteHttp(stream, 200, "text/plain", Encoding.UTF8.GetBytes("s3-mcp ok")); + return; + } + + if (method != "POST") + { + WriteHttp(stream, 405, "text/plain", Encoding.UTF8.GetBytes("POST only")); + return; + } + + if (!string.IsNullOrEmpty(_token)) + { + string auth = Header(headers, "authorization"); + string expect = "Bearer " + _token; + if (!string.Equals(auth, expect, StringComparison.Ordinal)) + { + WriteHttp(stream, 401, "text/plain", Encoding.UTF8.GetBytes("unauthorized")); + return; + } + } + + JObject req; + try + { + req = JObject.Parse(Encoding.UTF8.GetString(body)); + } + catch (Exception e) + { + WriteHttp(stream, 400, "application/json", + Encoding.UTF8.GetBytes("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32700,\"message\":\"" + + Escape(e.Message) + "\"}}")); + return; + } + + bool hasId = req["id"] != null && req["id"]!.Type != JTokenType.Null; + JObject? resp = _dispatch(req); + if (!hasId) + { + WriteHttp(stream, 202, "text/plain", Array.Empty()); + return; + } + if (resp == null) + resp = new JObject { ["jsonrpc"] = "2.0", ["id"] = req["id"], ["result"] = new JObject() }; + byte[] json = Encoding.UTF8.GetBytes(resp.ToString(Newtonsoft.Json.Formatting.None)); + WriteHttp(stream, 200, "application/json", json); + } + + static string Escape(string s) => (s ?? "").Replace("\\", "\\\\").Replace("\"", "\\\""); + + static string Header(Dictionary headers, string name) => + headers.TryGetValue(name, out string v) ? v : ""; + + static void WriteHttp(NetworkStream stream, int status, string contentType, byte[] body) + { + string reason = status == 200 ? "OK" : status == 202 ? "Accepted" : status == 401 ? "Unauthorized" + : status == 404 ? "Not Found" : status == 405 ? "Method Not Allowed" : "Error"; + var sb = new StringBuilder(); + sb.Append("HTTP/1.1 ").Append(status).Append(' ').Append(reason).Append("\r\n"); + sb.Append("Content-Type: ").Append(contentType).Append("\r\n"); + sb.Append("Content-Length: ").Append(body.Length).Append("\r\n"); + sb.Append("Connection: keep-alive\r\n"); + sb.Append("\r\n"); + byte[] head = Encoding.ASCII.GetBytes(sb.ToString()); + stream.Write(head, 0, head.Length); + if (body.Length > 0) + stream.Write(body, 0, body.Length); + stream.Flush(); + } + + static bool TryReadHttp( + NetworkStream stream, + out string method, + out string path, + out Dictionary headers, + out byte[] body) + { + method = ""; + path = ""; + headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + body = Array.Empty(); + + var ms = new MemoryStream(); + var buf = new byte[1024]; + int headerEnd = -1; + while (headerEnd < 0 && ms.Length < 64 * 1024) + { + int n; + try { n = stream.Read(buf, 0, buf.Length); } + catch { return false; } + if (n <= 0) return false; + ms.Write(buf, 0, n); + byte[] soFar = ms.ToArray(); + headerEnd = IndexOfHeaderEnd(soFar, soFar.Length); + } + if (headerEnd < 0) return false; + + byte[] all = ms.ToArray(); + string headerText = Encoding.ASCII.GetString(all, 0, headerEnd); + string[] lines = headerText.Split(new[] { "\r\n" }, StringSplitOptions.None); + if (lines.Length == 0) return false; + string[] req = lines[0].Split(' '); + if (req.Length < 2) return false; + method = req[0].ToUpperInvariant(); + path = req[1]; + int q = path.IndexOf('?'); + if (q >= 0) path = path.Substring(0, q); + + for (int i = 1; i < lines.Length; i++) + { + int colon = lines[i].IndexOf(':'); + if (colon <= 0) continue; + string key = lines[i].Substring(0, colon).Trim(); + string val = lines[i].Substring(colon + 1).Trim(); + headers[key] = val; + } + + int extra = all.Length - (headerEnd + 4); + int length = 0; + if (headers.TryGetValue("Content-Length", out string cl)) + int.TryParse(cl, out length); + if (length < 0) length = 0; + if (length > 1_000_000) return false; + + var bodyBuf = new MemoryStream(); + if (extra > 0) + bodyBuf.Write(all, headerEnd + 4, extra); + while (bodyBuf.Length < length) + { + int need = length - (int)bodyBuf.Length; + int n; + try { n = stream.Read(buf, 0, Math.Min(buf.Length, need)); } + catch { return false; } + if (n <= 0) return false; + bodyBuf.Write(buf, 0, n); + } + body = bodyBuf.ToArray(); + if (body.Length > length && length > 0) + { + var trimmed = new byte[length]; + Buffer.BlockCopy(body, 0, trimmed, 0, length); + body = trimmed; + } + return true; + } + + static int IndexOfHeaderEnd(byte[] data, int len) + { + for (int i = 0; i <= len - 4; i++) + { + if (data[i] == 13 && data[i + 1] == 10 && data[i + 2] == 13 && data[i + 3] == 10) + return i; + } + return -1; + } +} diff --git a/src/Modules/Mcp/McpSettings.cs b/src/Modules/Mcp/McpSettings.cs new file mode 100644 index 0000000..cf22312 --- /dev/null +++ b/src/Modules/Mcp/McpSettings.cs @@ -0,0 +1,19 @@ +using System; + +namespace S3.Modules.Mcp; + +[Serializable] +public class McpSettings +{ + public bool enabled = false; + + public int port = 18765; + public string token = "s3-local-dev"; + + public bool observe = true; + public bool control = false; + public bool develop = true; + + public int callTimeoutMs = 8000; + public bool autoReload = true; +} diff --git a/src/Modules/Mcp/McpSettingsUI.cs b/src/Modules/Mcp/McpSettingsUI.cs new file mode 100644 index 0000000..27c47e9 --- /dev/null +++ b/src/Modules/Mcp/McpSettingsUI.cs @@ -0,0 +1,80 @@ +using S3.Mcp; +using UnityEngine; + +namespace S3.Modules.Mcp; + +static class McpSettingsUI +{ + public static void Draw() + { + var s = McpModule.Settings; + bool changed = false; + + GUILayout.BeginVertical(); + GUILayout.Label("MCP - localhost agent socket. Tools live in a reloadable DLL."); + GUILayout.Space(4f); + GUILayout.Label( + " Cursor talks to this process over HTTP MCP on 127.0.0.1.\n" + + " Rebuild tools while the game is running: dist\\build-mcp-tools.ps1\n" + + " The host reloads S3.Mcp.Tools.dll automatically (or /s3mcp reload).\n" + + " Changing the host itself (this module in S3.dll) still needs a game restart.", + GUI.skin.label); + + GUILayout.Space(8f); + var host = McpHost.Instance; + string listen = host != null ? host.ListenSummary : "(not running this launch)"; + GUILayout.Label("Status: " + listen); + GUILayout.Label("URL: " + (host != null ? host.Url : "-")); + GUILayout.Label("Token: " + s.token); + + GUILayout.BeginHorizontal(); + if (GUILayout.Button("Reload tools", GUILayout.Width(140f))) + host?.RequestReload(restartServer: true); + if (GUILayout.Button("Restart server", GUILayout.Width(140f))) + host?.RequestRestart(); + GUILayout.EndHorizontal(); + + GUILayout.Space(10f); + GUILayout.Label("Gates (only from this panel, not via MCP)"); + changed |= Toggle(ref s.observe, " Observe - read state, dumps, hover, screenshot"); + changed |= Toggle(ref s.control, " Control - mutate the live save (freeze, select)"); + changed |= Toggle(ref s.develop, " Develop - extra plugin DLLs besides S3.Mcp.Tools"); + + GUILayout.Space(10f); + GUILayout.Label("Listen"); + GUILayout.BeginHorizontal(); + GUILayout.Label("Port", GUILayout.Width(80f)); + string portStr = GUILayout.TextField(s.port.ToString(), GUILayout.Width(80f)); + if (int.TryParse(portStr, out int p) && p != s.port && p > 0 && p < 65536) + { + s.port = p; + changed = true; + } + GUILayout.EndHorizontal(); + + GUILayout.BeginHorizontal(); + GUILayout.Label("Token", GUILayout.Width(80f)); + string tok = GUILayout.TextField(s.token ?? "", GUILayout.Width(280f)); + if (tok != s.token) + { + s.token = tok; + changed = true; + } + GUILayout.EndHorizontal(); + + changed |= Toggle(ref s.autoReload, " Auto-reload S3.Mcp.Tools.dll when the file changes"); + + GUILayout.EndVertical(); + + if (changed) + McpModule.Persist(); + } + + static bool Toggle(ref bool value, string label) + { + bool next = GUILayout.Toggle(value, label); + if (next == value) return false; + value = next; + return true; + } +}