MCP: localhost agent server with reloadable S3.Mcp.Tools plugin pack
Loopback MCP host with observe/control/develop gates and a hot-reload tools DLL. Default token is local-dev only. Leave the module off unless you are driving S3 from an agent.
This commit is contained in:
parent
48c9b97029
commit
5e18cb84a1
22 changed files with 3612 additions and 9 deletions
11
README.md
11
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
|
||||
|
|
|
|||
74
dist/build-common.ps1
vendored
74
dist/build-common.ps1
vendored
|
|
@ -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
|
||||
|
|
|
|||
22
dist/build-local.ps1
vendored
22
dist/build-local.ps1
vendored
|
|
@ -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
|
||||
|
|
|
|||
7
dist/build-mcp-tools.ps1
vendored
Normal file
7
dist/build-mcp-tools.ps1
vendored
Normal file
|
|
@ -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
|
||||
241
mcp-tools/DeepMethodProfiler.cs
Normal file
241
mcp-tools/DeepMethodProfiler.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<MethodBase, string> 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>();
|
||||
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<Type>(); }
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
116
mcp-tools/PlayerLoopProfiler.cs
Normal file
116
mcp-tools/PlayerLoopProfiler.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using S3.Modules.Profiler;
|
||||
using UnityEngine.LowLevel;
|
||||
|
||||
namespace S3.Mcp.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Temporarily inserts timestamp boundaries around every Unity PlayerLoop
|
||||
/// subsystem. This measures native engine phases that Harmony cannot patch.
|
||||
/// </summary>
|
||||
internal static class PlayerLoopProfiler
|
||||
{
|
||||
static PlayerLoopSystem _original;
|
||||
static long[] _depthStarts = Array.Empty<long>();
|
||||
|
||||
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<long>();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
60
mcp-tools/S3.Mcp.Tools.csproj
Normal file
60
mcp-tools/S3.Mcp.Tools.csproj
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>annotations</Nullable>
|
||||
<!-- Unique assembly identity every build so Unity Mono can Assembly.Load
|
||||
a new copy without colliding with the previous one. File is copied
|
||||
to the stable name S3.Mcp.Tools.dll; the host loads bytes. -->
|
||||
<ToolsStamp>$([System.DateTime]::UtcNow.ToString('yyyyMMddHHmmssfff'))</ToolsStamp>
|
||||
<AssemblyName>S3.Mcp.Tools_$(ToolsStamp)</AssemblyName>
|
||||
<RootNamespace>S3.Mcp.Tools</RootNamespace>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<S3Dll>$(MSBuildThisFileDirectory)..\src\bin\$(Configuration)\S3.dll</S3Dll>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="S3">
|
||||
<HintPath>$(S3Dll)</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Assembly-CSharp">
|
||||
<HintPath>$(GameManaged)\Assembly-CSharp.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Newtonsoft.Json">
|
||||
<HintPath>$(GameManaged)\Newtonsoft.Json.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine">
|
||||
<HintPath>$(GameManaged)\UnityEngine.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.CoreModule">
|
||||
<HintPath>$(GameManaged)\UnityEngine.CoreModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.PhysicsModule">
|
||||
<HintPath>$(GameManaged)\UnityEngine.PhysicsModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.InputLegacyModule">
|
||||
<HintPath>$(GameManaged)\UnityEngine.InputLegacyModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(UmmDir)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyStablePlugin" AfterTargets="Build">
|
||||
<MakeDir Directories="$(GameDir)\Mods\S3\plugins" />
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFiles="$(GameDir)\Mods\S3\plugins\S3.Mcp.Tools.dll" />
|
||||
<Message Importance="high" Text="Copied S3.Mcp.Tools.dll (assembly $(AssemblyName))" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
145
mcp-tools/SparseHitchSampler.cs
Normal file
145
mcp-tools/SparseHitchSampler.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using S3.Modules.Profiler;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Mcp.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Low-allocation manual sampler. It stores full probe detail only for hitch
|
||||
/// frames and ignores sub-0.1ms samples, avoiding profiler-induced GC stalls.
|
||||
/// </summary>
|
||||
internal static class SparseHitchSampler
|
||||
{
|
||||
static readonly Dictionary<string, HitchProbeSample> Current =
|
||||
new(StringComparer.Ordinal);
|
||||
static readonly List<HitchFrameRecord> 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<SparseHitchFrameDriver>();
|
||||
}
|
||||
|
||||
public static List<HitchFrameRecord> End()
|
||||
{
|
||||
Active = false;
|
||||
DestroyDriver();
|
||||
Current.Clear();
|
||||
_pending = false;
|
||||
return new List<HitchFrameRecord>(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);
|
||||
}
|
||||
1029
mcp-tools/ToolsPlugin.cs
Normal file
1029
mcp-tools/ToolsPlugin.cs
Normal file
File diff suppressed because it is too large
Load diff
197
mcp-tools/UnityMarkerProfiler.cs
Normal file
197
mcp-tools/UnityMarkerProfiler.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>Capture-scoped Unity PlayerLoop and subsystem marker recorders.</summary>
|
||||
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<Entry> Entries = new();
|
||||
static GameObject? _driverObject;
|
||||
|
||||
public static int ActiveMarkers => Entries.Count;
|
||||
|
||||
public static string Start()
|
||||
{
|
||||
Stop();
|
||||
var names = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var available = new List<ProfilerRecorderHandle>();
|
||||
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<UnityMarkerFrameDriver>();
|
||||
}
|
||||
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<string> names,
|
||||
HashSet<string> 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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
51
src/Modules/Mcp/McpConsole.cs
Normal file
51
src/Modules/Mcp/McpConsole.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
286
src/Modules/Mcp/McpGame.cs
Normal file
286
src/Modules/Mcp/McpGame.cs
Normal file
|
|
@ -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<uint>();
|
||||
var seenLoose = new HashSet<string>();
|
||||
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<ConsoleCommandHandler>();
|
||||
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<string>;
|
||||
comps = list != null ? list.ToArray() : Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
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<string>();
|
||||
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<PersistentLoader>();
|
||||
if (_loader == null) return false;
|
||||
_loadingScreen = Traverse.Create(_loader).Field("loadingScreen").GetValue<GameObject>();
|
||||
return _loadingScreen != null && _loadingScreen.activeInHierarchy;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
362
src/Modules/Mcp/McpHost.cs
Normal file
362
src/Modules/Mcp/McpHost.cs
Normal file
|
|
@ -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<McpWork> _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<bool?>() != 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<string>() ?? "";
|
||||
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<string>() ?? "")));
|
||||
|
||||
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<string>() ?? "";
|
||||
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<int?>() ?? 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<JObject, McpToolResult> 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;
|
||||
}
|
||||
}
|
||||
66
src/Modules/Mcp/McpModule.cs
Normal file
66
src/Modules/Mcp/McpModule.cs
Normal file
|
|
@ -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<McpSettings>(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<McpHost>();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
243
src/Modules/Mcp/McpPlugins.cs
Normal file
243
src/Modules/Mcp/McpPlugins.cs
Normal file
|
|
@ -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<LoadedPlugin> _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<LoadedPlugin> 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<JObject, McpToolResult> 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,
|
||||
};
|
||||
}
|
||||
183
src/Modules/Mcp/McpProtocol.cs
Normal file
183
src/Modules/Mcp/McpProtocol.cs
Normal file
|
|
@ -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<string>() ?? "";
|
||||
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<string>() ?? "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<string>() ?? "";
|
||||
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;
|
||||
}
|
||||
}
|
||||
63
src/Modules/Mcp/McpPublic.cs
Normal file
63
src/Modules/Mcp/McpPublic.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace S3.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Public contract compiled into S3.dll. Reloadable tool assemblies
|
||||
/// (S3.Mcp.Tools.dll and extra plugins) implement <see cref="IAgentPlugin"/>
|
||||
/// and talk to the host only through these types.
|
||||
/// </summary>
|
||||
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<JObject, McpToolResult> 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);
|
||||
}
|
||||
62
src/Modules/Mcp/McpRegistry.cs
Normal file
62
src/Modules/Mcp/McpRegistry.cs
Normal file
|
|
@ -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<JObject, McpToolResult> Handler = _ => McpToolResult.Fail("no handler");
|
||||
}
|
||||
|
||||
sealed class McpRegistry
|
||||
{
|
||||
readonly List<McpToolEntry> _tools = new();
|
||||
|
||||
public IReadOnlyList<McpToolEntry> 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<McpToolEntry> Visible()
|
||||
{
|
||||
var s = McpModule.Settings;
|
||||
var list = new List<McpToolEntry>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
303
src/Modules/Mcp/McpServer.cs
Normal file
303
src/Modules/Mcp/McpServer.cs
Normal file
|
|
@ -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<JObject, JObject?> _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<JObject, JObject?> 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<string, string> 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<byte>());
|
||||
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<string, string> 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<string, string> headers,
|
||||
out byte[] body)
|
||||
{
|
||||
method = "";
|
||||
path = "";
|
||||
headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
body = Array.Empty<byte>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
19
src/Modules/Mcp/McpSettings.cs
Normal file
19
src/Modules/Mcp/McpSettings.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
80
src/Modules/Mcp/McpSettingsUI.cs
Normal file
80
src/Modules/Mcp/McpSettingsUI.cs
Normal file
|
|
@ -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("<b>MCP</b> - 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("<b>Gates</b> (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("<b>Listen</b>");
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue