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. |
|
| 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. |
|
| 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. |
|
| 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.
|
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
|
## Migrating from the standalone mods
|
||||||
|
|
||||||
S³ replaces the separate **Physics Optimizer** (`RailroaderPhysicsOverhaul`) and
|
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
|
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 {
|
function Invoke-NativeBuild {
|
||||||
param([string]$Configuration = "Release")
|
param([string]$Configuration = "Release")
|
||||||
|
|
||||||
|
|
@ -50,6 +61,51 @@ function Invoke-NativeBuild {
|
||||||
return $dll
|
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).
|
# 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
|
# Overwrites in place rather than wiping the folder. A full wipe is dangerous: if
|
||||||
|
|
@ -60,18 +116,20 @@ function Invoke-NativeBuild {
|
||||||
function New-ModLayout {
|
function New-ModLayout {
|
||||||
param([Parameter(Mandatory)][string]$DestModDir,
|
param([Parameter(Mandatory)][string]$DestModDir,
|
||||||
[Parameter(Mandatory)][string]$ManagedDll,
|
[Parameter(Mandatory)][string]$ManagedDll,
|
||||||
[string]$NativeDll = $null)
|
[string]$NativeDll = $null,
|
||||||
|
[switch]$Wait)
|
||||||
|
|
||||||
New-Item -ItemType Directory -Force -Path $DestModDir | Out-Null
|
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
|
# Fail fast with a clear message if a DLL we're about to overwrite is locked
|
||||||
# (game still running) — before touching anything.
|
# (game still running) — before touching anything.
|
||||||
foreach ($name in @("S3.dll", "S3Native.dll")) {
|
$locked = @(Get-LockedModDlls $DestModDir)
|
||||||
$path = Join-Path $DestModDir $name
|
if ($locked.Count -gt 0) {
|
||||||
if (Test-Path $path) {
|
throw "$($locked -join ', ') is locked - close Railroader before installing (or rerun with -Wait)."
|
||||||
try { [System.IO.File]::Open($path, 'Open', 'ReadWrite', 'None').Close() }
|
|
||||||
catch { throw "$name is locked - close Railroader before installing." }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Drop stale artifacts from older builds (the pre-rename native DLL, ngen
|
# 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 (Join-Path $RepoRoot "Info.json") (Join-Path $DestModDir "Info.json") -Force
|
||||||
Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -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) {
|
if ($NativeDll) {
|
||||||
Copy-Item $NativeDll (Join-Path $DestModDir "S3Native.dll") -Force
|
Copy-Item $NativeDll (Join-Path $DestModDir "S3Native.dll") -Force
|
||||||
# Ship ProggyClean.ttf alongside the native DLL so IMGUI_DISABLE_DEFAULT_FONT
|
# 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.
|
# Build S³ and install it straight into the local game copy for testing.
|
||||||
# .\dist\build-local.ps1
|
# .\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
|
# .\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(
|
param(
|
||||||
[string]$Configuration = "Release",
|
[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")
|
. (Join-Path $PSScriptRoot "build-common.ps1")
|
||||||
|
|
||||||
if (-not (Test-Path $GameDir)) { throw "GameDir not found: $GameDir" }
|
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
|
$managed = Invoke-ManagedBuild -Configuration $Configuration -GameDir $GameDir
|
||||||
$native = Invoke-NativeBuild -Configuration $Configuration
|
$native = Invoke-NativeBuild -Configuration $Configuration
|
||||||
|
|
||||||
$modDir = Join-Path $GameDir "Mods\$ModId"
|
$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
|
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.QuickActions.QuickActionsModule());
|
||||||
_registry.Register(new Modules.CarCards.CarCardsModule());
|
_registry.Register(new Modules.CarCards.CarCardsModule());
|
||||||
_registry.Register(new Modules.IndustryTags.IndustryTagsModule());
|
_registry.Register(new Modules.IndustryTags.IndustryTagsModule());
|
||||||
|
_registry.Register(new Modules.Mcp.McpModule());
|
||||||
|
|
||||||
_registry.EnableConfigured();
|
_registry.EnableConfigured();
|
||||||
ModConflicts.CheckAtLoad();
|
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