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;
+ }
+}